diff --git a/backend/package-lock.json b/backend/package-lock.json
index 5255f48e..b44de2a2 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -12,7 +12,8 @@
         "cors": "^2.8.5",
         "cross-env": "^5.2.0",
         "deep-diff": "^1.0.2",
-        "discord.js": "^13.0.1",
+        "discord-api-types": "^0.22.0",
+        "discord.js": "^13.1.0",
         "dotenv": "^4.0.0",
         "emoji-regex": "^8.0.0",
         "erlpack": "github:almeidx/erlpack#f0c535f73817fd914806d6ca26a7730c14e0fb7c",
@@ -81,8 +82,8 @@
       "version": "30.0.0-beta.38",
       "license": "MIT",
       "dependencies": {
-        "discord-api-types": "^0.21.0-next.ab1951b.1626870574",
-        "discord.js": "^13.0.1",
+        "discord-api-types": "^0.22.0",
+        "discord.js": "^13.1.0",
         "knub-command-manager": "^9.1.0",
         "ts-essentials": "^6.0.7"
       },
@@ -161,9 +162,9 @@
       }
     },
     "node_modules/@discordjs/builders": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.4.0.tgz",
-      "integrity": "sha512-EiwLltKph6TSaPJIzJYdzNc1PnA2ZNaaE0t0ODg3ghnpVHqfgd0YX9/srsleYHW2cw1sfIq+kbM+h0etf7GWLA==",
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.5.0.tgz",
+      "integrity": "sha512-HP5y4Rqw68o61Qv4qM5tVmDbWi4mdTFftqIOGRo33SNPpLJ1Ga3KEIR2ibKofkmsoQhEpLmopD1AZDs3cKpHuw==",
       "dependencies": {
         "@sindresorhus/is": "^4.0.1",
         "discord-api-types": "^0.22.0",
@@ -188,9 +189,9 @@
       }
     },
     "node_modules/@discordjs/builders/node_modules/tslib": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
-      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
     },
     "node_modules/@discordjs/collection": {
       "version": "0.2.1",
@@ -2166,11 +2167,11 @@
       }
     },
     "node_modules/discord.js": {
-      "version": "13.0.1",
-      "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.1.tgz",
-      "integrity": "sha512-pEODCFfxypBnGEYpSgjkn1jt70raCS1um7Zp0AXEfW1DcR29wISzQ/WeWdnjP5KTXGi0LTtkRiUjOsMgSoukxA==",
+      "version": "13.1.0",
+      "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.1.0.tgz",
+      "integrity": "sha512-gxO4CXKdHpqA+WKG+f5RNnd3srTDj5uFJHgOathksDE90YNq/Qijkd2WlMgTTMS6AJoEnHxI7G9eDQHCuZ+xDA==",
       "dependencies": {
-        "@discordjs/builders": "^0.4.0",
+        "@discordjs/builders": "^0.5.0",
         "@discordjs/collection": "^0.2.1",
         "@discordjs/form-data": "^3.0.1",
         "@sapphire/async-queue": "^1.1.4",
@@ -6449,9 +6450,9 @@
       }
     },
     "@discordjs/builders": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.4.0.tgz",
-      "integrity": "sha512-EiwLltKph6TSaPJIzJYdzNc1PnA2ZNaaE0t0ODg3ghnpVHqfgd0YX9/srsleYHW2cw1sfIq+kbM+h0etf7GWLA==",
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.5.0.tgz",
+      "integrity": "sha512-HP5y4Rqw68o61Qv4qM5tVmDbWi4mdTFftqIOGRo33SNPpLJ1Ga3KEIR2ibKofkmsoQhEpLmopD1AZDs3cKpHuw==",
       "requires": {
         "@sindresorhus/is": "^4.0.1",
         "discord-api-types": "^0.22.0",
@@ -6466,9 +6467,9 @@
           "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g=="
         },
         "tslib": {
-          "version": "2.3.0",
-          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
-          "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
         }
       }
     },
@@ -8047,11 +8048,11 @@
       "integrity": "sha512-l8yD/2zRbZItUQpy7ZxBJwaLX/Bs2TGaCthRppk8Sw24LOIWg12t9JEreezPoYD0SQcC2htNNo27kYEpYW/Srg=="
     },
     "discord.js": {
-      "version": "13.0.1",
-      "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.1.tgz",
-      "integrity": "sha512-pEODCFfxypBnGEYpSgjkn1jt70raCS1um7Zp0AXEfW1DcR29wISzQ/WeWdnjP5KTXGi0LTtkRiUjOsMgSoukxA==",
+      "version": "13.1.0",
+      "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.1.0.tgz",
+      "integrity": "sha512-gxO4CXKdHpqA+WKG+f5RNnd3srTDj5uFJHgOathksDE90YNq/Qijkd2WlMgTTMS6AJoEnHxI7G9eDQHCuZ+xDA==",
       "requires": {
-        "@discordjs/builders": "^0.4.0",
+        "@discordjs/builders": "^0.5.0",
         "@discordjs/collection": "^0.2.1",
         "@discordjs/form-data": "^3.0.1",
         "@sapphire/async-queue": "^1.1.4",
@@ -8867,8 +8868,8 @@
         "@typescript-eslint/eslint-plugin": "^4.23.0",
         "@typescript-eslint/parser": "^4.23.0",
         "chai": "^4.3.4",
-        "discord-api-types": "^0.21.0-next.ab1951b.1626870574",
-        "discord.js": "^13.0.1",
+        "discord-api-types": "^0.22.0",
+        "discord.js": "^13.1.0",
         "eslint": "^7.2.0",
         "husky": "^4.3.8",
         "knub-command-manager": "^9.1.0",
diff --git a/backend/package.json b/backend/package.json
index 582edf50..3f9589e4 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -27,7 +27,8 @@
     "cors": "^2.8.5",
     "cross-env": "^5.2.0",
     "deep-diff": "^1.0.2",
-    "discord.js": "^13.0.1",
+    "discord-api-types": "^0.22.0",
+    "discord.js": "^13.1.0",
     "dotenv": "^4.0.0",
     "emoji-regex": "^8.0.0",
     "erlpack": "github:almeidx/erlpack#f0c535f73817fd914806d6ca26a7730c14e0fb7c",
diff --git a/backend/src/data/GuildContextMenuLinks.ts b/backend/src/data/GuildContextMenuLinks.ts
new file mode 100644
index 00000000..76afd3da
--- /dev/null
+++ b/backend/src/data/GuildContextMenuLinks.ts
@@ -0,0 +1,35 @@
+import { DeleteResult, getRepository, InsertResult, Repository } from "typeorm";
+import { BaseGuildRepository } from "./BaseGuildRepository";
+import { ContextMenuLink } from "./entities/ContextMenuLink";
+
+export class GuildContextMenuLinks extends BaseGuildRepository {
+  private contextLinks: Repository<ContextMenuLink>;
+
+  constructor(guildId) {
+    super(guildId);
+    this.contextLinks = getRepository(ContextMenuLink);
+  }
+
+  async get(id: string): Promise<ContextMenuLink | undefined> {
+    return this.contextLinks.findOne({
+      where: {
+        guild_id: this.guildId,
+        context_id: id,
+      },
+    });
+  }
+
+  async create(contextId: string, contextAction: string): Promise<InsertResult> {
+    return this.contextLinks.insert({
+      guild_id: this.guildId,
+      context_id: contextId,
+      action_name: contextAction,
+    });
+  }
+
+  async deleteAll(): Promise<DeleteResult> {
+    return this.contextLinks.delete({
+      guild_id: this.guildId,
+    });
+  }
+}
diff --git a/backend/src/data/entities/ContextMenuLink.ts b/backend/src/data/entities/ContextMenuLink.ts
new file mode 100644
index 00000000..df66fae3
--- /dev/null
+++ b/backend/src/data/entities/ContextMenuLink.ts
@@ -0,0 +1,10 @@
+import { Column, Entity, PrimaryColumn } from "typeorm";
+
+@Entity("context_menus")
+export class ContextMenuLink {
+  @Column() guild_id: string;
+
+  @Column() @PrimaryColumn() context_id: string;
+
+  @Column() action_name: string;
+}
diff --git a/backend/src/migrations/1628809879962-CreateContextMenuTable.ts b/backend/src/migrations/1628809879962-CreateContextMenuTable.ts
new file mode 100644
index 00000000..236b7b3c
--- /dev/null
+++ b/backend/src/migrations/1628809879962-CreateContextMenuTable.ts
@@ -0,0 +1,32 @@
+import { MigrationInterface, QueryRunner, Table } from "typeorm";
+
+export class CreateContextMenuTable1628809879962 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.createTable(
+      new Table({
+        name: "context_menus",
+        columns: [
+          {
+            name: "guild_id",
+            type: "bigint",
+          },
+          {
+            name: "context_id",
+            type: "bigint",
+            isPrimary: true,
+            isUnique: true,
+          },
+          {
+            name: "action_name",
+            type: "varchar",
+            length: "100",
+          },
+        ],
+      }),
+    );
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.dropTable("context_menus");
+  }
+}
diff --git a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts
new file mode 100644
index 00000000..7aefe88f
--- /dev/null
+++ b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts
@@ -0,0 +1,61 @@
+import { PluginOptions } from "knub";
+import { StrictValidationError } from "src/validatorUtils";
+import { ConfigPreprocessorFn } from "../../../../../Knub/dist/config/configTypes";
+import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks";
+import { LogsPlugin } from "../Logs/LogsPlugin";
+import { MutesPlugin } from "../Mutes/MutesPlugin";
+import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
+import { availableTypes } from "./actions/availableActions";
+import { ContextClickedEvt } from "./events/ContextClickedEvt";
+import { ConfigSchema, ContextMenuPluginType } from "./types";
+import { loadAllCommands } from "./utils/loadAllCommands";
+
+const defaultOptions: PluginOptions<ContextMenuPluginType> = {
+  config: {
+    context_actions: {},
+  },
+};
+
+const configPreprocessor: ConfigPreprocessorFn<ContextMenuPluginType> = options => {
+  if (options.config.context_actions) {
+    for (const [name, contextMenu] of Object.entries(options.config.context_actions)) {
+      if (Object.entries(contextMenu.action).length !== 1) {
+        throw new StrictValidationError([`Invalid value for context_actions/${name}: Must have exactly one action.`]);
+      }
+
+      const actionName = Object.entries(contextMenu.action)[0][0];
+      if (!availableTypes[actionName].includes(contextMenu.type)) {
+        throw new StrictValidationError([
+          `Invalid value for context_actions/${name}/${actionName}: ${actionName} is not allowed on type ${contextMenu.type}.`,
+        ]);
+      }
+    }
+  }
+
+  return options;
+};
+
+export const ContextMenuPlugin = zeppelinGuildPlugin<ContextMenuPluginType>()({
+  name: "context_menu",
+
+  configSchema: ConfigSchema,
+  defaultOptions,
+  configPreprocessor,
+
+  // prettier-ignore
+  events: [
+    ContextClickedEvt,
+  ],
+
+  beforeLoad(pluginData) {
+    const { state, guild } = pluginData;
+
+    state.contextMenuLinks = new GuildContextMenuLinks(guild.id);
+  },
+
+  afterLoad(pluginData) {
+    loadAllCommands(pluginData);
+  },
+
+  dependencies: [MutesPlugin, LogsPlugin],
+});
diff --git a/backend/src/plugins/ContextMenus/actions/availableActions.ts b/backend/src/plugins/ContextMenus/actions/availableActions.ts
new file mode 100644
index 00000000..24816790
--- /dev/null
+++ b/backend/src/plugins/ContextMenus/actions/availableActions.ts
@@ -0,0 +1,19 @@
+import * as t from "io-ts";
+import { ContextActionBlueprint } from "../helpers";
+import { CleanAction } from "./clean";
+import { MuteAction } from "./mute";
+
+export const availableActions: Record<string, ContextActionBlueprint<any>> = {
+  mute: MuteAction,
+  clean: CleanAction,
+};
+
+export const AvailableActions = t.type({
+  mute: MuteAction.configType,
+  clean: CleanAction.configType,
+});
+
+export const availableTypes: Record<string, string[]> = {
+  mute: ["USER"],
+  clean: ["MESSAGE"],
+};
diff --git a/backend/src/plugins/ContextMenus/actions/clean.ts b/backend/src/plugins/ContextMenus/actions/clean.ts
new file mode 100644
index 00000000..8184d480
--- /dev/null
+++ b/backend/src/plugins/ContextMenus/actions/clean.ts
@@ -0,0 +1,64 @@
+import { TextChannel } from "discord.js";
+import * as t from "io-ts";
+import { canActOn } from "src/pluginUtils";
+import { LogType } from "../../../data/LogType";
+import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin";
+import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
+import { tNullable } from "../../../utils";
+import { LogsPlugin } from "../../Logs/LogsPlugin";
+import { contextMenuAction } from "../helpers";
+
+export const CleanAction = contextMenuAction({
+  configType: t.type({
+    amount: tNullable(t.number),
+    targetUserOnly: tNullable(t.boolean),
+    "delete-pins": tNullable(t.boolean),
+  }),
+
+  defaultConfig: {
+    amount: 10,
+    targetUserOnly: false,
+    "delete-pins": false,
+  },
+
+  async apply({ pluginData, actionConfig, actionName, interaction }) {
+    interaction.deferReply({ ephemeral: true });
+    const targetMessage = interaction.channel
+      ? await interaction.channel.messages.fetch(interaction.targetId)
+      : await (pluginData.guild.channels.resolve(interaction.channelId) as TextChannel).messages.fetch(
+          interaction.targetId,
+        );
+
+    const amount = actionConfig.amount ?? 10;
+    const targetUserOnly = actionConfig.targetUserOnly ?? false;
+    const deletePins = actionConfig["delete-pins"] ?? false;
+
+    const user = targetUserOnly ? targetMessage.author.id : undefined;
+    const targetMember = await pluginData.guild.members.fetch(targetMessage.author.id);
+    const executingMember = await pluginData.guild.members.fetch(interaction.user.id);
+    const utility = pluginData.getPlugin(UtilityPlugin);
+
+    if (targetUserOnly && !canActOn(pluginData, executingMember, targetMember)) {
+      interaction.followUp({ ephemeral: true, content: "Cannot clean users messages: insufficient permissions" });
+      return;
+    }
+
+    try {
+      interaction.followUp(`Cleaning... Amount: ${amount}, User Only: ${targetUserOnly}, Pins: ${deletePins}`);
+      utility.clean(
+        { count: amount, user, channel: targetMessage.channel.id, "delete-pins": deletePins },
+        targetMessage,
+      );
+    } catch (e) {
+      interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" });
+
+      if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {
+        pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, {
+          body: `Failed to clean in <#${interaction.channelId}> in ContextMenu action \`${actionName}\``,
+        });
+      } else {
+        throw e;
+      }
+    }
+  },
+});
diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts
new file mode 100644
index 00000000..00a9c131
--- /dev/null
+++ b/backend/src/plugins/ContextMenus/actions/mute.ts
@@ -0,0 +1,83 @@
+import humanizeDuration from "humanize-duration";
+import * as t from "io-ts";
+import { canActOn } from "src/pluginUtils";
+import { LogType } from "../../../data/LogType";
+import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
+import { convertDelayStringToMS, tDelayString, tNullable } from "../../../utils";
+import { CaseArgs } from "../../Cases/types";
+import { LogsPlugin } from "../../Logs/LogsPlugin";
+import { MutesPlugin } from "../../Mutes/MutesPlugin";
+import { contextMenuAction } from "../helpers";
+import { resolveActionContactMethods } from "../utils/resolveActionContactMethods";
+
+export const MuteAction = contextMenuAction({
+  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),
+  }),
+
+  defaultConfig: {
+    notify: null, // Use defaults from ModActions
+    hide_case: false,
+  },
+
+  async apply({ pluginData, actionConfig, actionName, interaction }) {
+    const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined;
+    const reason = actionConfig.reason || "Context Menu Action";
+    const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;
+    const rolesToRemove = actionConfig.remove_roles_on_mute;
+    const rolesToRestore = actionConfig.restore_roles_on_mute;
+
+    const caseArgs: Partial<CaseArgs> = {
+      modId: pluginData.client.user!.id,
+      automatic: true,
+      postInCaseLogOverride: actionConfig.postInCaseLog ?? undefined,
+      hide: Boolean(actionConfig.hide_case),
+    };
+
+    interaction.deferReply({ ephemeral: true });
+    const mutes = pluginData.getPlugin(MutesPlugin);
+    const userId = interaction.targetId;
+    const targetMember = await pluginData.guild.members.fetch(interaction.targetId);
+    const executingMember = await pluginData.guild.members.fetch(interaction.user.id);
+
+    if (!canActOn(pluginData, executingMember, targetMember)) {
+      interaction.followUp({ ephemeral: true, content: "Cannot mute: insufficient permissions" });
+      return;
+    }
+
+    try {
+      const result = await mutes.muteUser(
+        userId,
+        duration,
+        reason,
+        { contactMethods, caseArgs, isAutomodAction: true },
+        rolesToRemove,
+        rolesToRestore,
+      );
+
+      const muteMessage = `Muted **${result.case.user_name}** ${
+        duration ? `for ${humanizeDuration(duration)}` : "indefinitely"
+      } (Case #${result.case.case_number}) (user notified via ${result.notifyResult.method ??
+        "dm"})\nPlease update the new case with the \`update\` command`;
+
+      interaction.followUp({ ephemeral: true, content: muteMessage });
+    } catch (e) {
+      interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" });
+
+      if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {
+        pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, {
+          body: `Failed to mute <@!${userId}> in ContextMenu action \`${actionName}\` because a mute role has not been specified in server config`,
+        });
+      } else {
+        throw e;
+      }
+    }
+  },
+});
diff --git a/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts b/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts
new file mode 100644
index 00000000..62675e41
--- /dev/null
+++ b/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts
@@ -0,0 +1,13 @@
+import { ContextMenuInteraction } from "discord.js";
+import { contextMenuEvt } from "../types";
+import { routeContextAction } from "../utils/contextRouter";
+
+export const ContextClickedEvt = contextMenuEvt({
+  event: "interactionCreate",
+
+  async listener(meta) {
+    if (!meta.args.interaction.isContextMenu) return;
+    const inter = meta.args.interaction as ContextMenuInteraction;
+    await routeContextAction(meta.pluginData, inter);
+  },
+});
diff --git a/backend/src/plugins/ContextMenus/helpers.ts b/backend/src/plugins/ContextMenus/helpers.ts
new file mode 100644
index 00000000..5a98f505
--- /dev/null
+++ b/backend/src/plugins/ContextMenus/helpers.ts
@@ -0,0 +1,25 @@
+import { ContextMenuInteraction } from "discord.js";
+import * as t from "io-ts";
+import { GuildPluginData } from "../../../../../Knub/dist";
+import { Awaitable } from "../../../../../Knub/dist/utils";
+import { ContextMenuPluginType } from "./types";
+
+type ContextActionApplyFn<TConfigType> = (meta: {
+  actionName: string;
+  pluginData: GuildPluginData<ContextMenuPluginType>;
+  actionConfig: TConfigType;
+  interaction: ContextMenuInteraction;
+}) => Awaitable<void>;
+
+export interface ContextActionBlueprint<TConfigType extends t.Any> {
+  configType: TConfigType;
+  defaultConfig: Partial<t.TypeOf<TConfigType>>;
+
+  apply: ContextActionApplyFn<t.TypeOf<TConfigType>>;
+}
+
+export function contextMenuAction<TConfigType extends t.Any>(
+  blueprint: ContextActionBlueprint<TConfigType>,
+): ContextActionBlueprint<TConfigType> {
+  return blueprint;
+}
diff --git a/backend/src/plugins/ContextMenus/types.ts b/backend/src/plugins/ContextMenus/types.ts
new file mode 100644
index 00000000..9b34cb6a
--- /dev/null
+++ b/backend/src/plugins/ContextMenus/types.ts
@@ -0,0 +1,38 @@
+import * as t from "io-ts";
+import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub";
+import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks";
+import { tNullable } from "../../utils";
+import { AvailableActions } from "./actions/availableActions";
+
+export enum ContextMenuTypes {
+  USER = 2,
+  MESSAGE = 3,
+}
+
+export const ContextMenuTypeNameToNumber: Record<string, number> = {
+  USER: 2,
+  MESSAGE: 3,
+};
+
+const ContextActionOpts = t.type({
+  enabled: tNullable(t.boolean),
+  label: t.string,
+  type: t.keyof(ContextMenuTypes),
+  action: t.partial(AvailableActions.props),
+});
+export type TContextActionOpts = t.TypeOf<typeof ContextActionOpts>;
+
+export const ConfigSchema = t.type({
+  context_actions: t.record(t.string, ContextActionOpts),
+});
+export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+
+export interface ContextMenuPluginType extends BasePluginType {
+  config: TConfigSchema;
+  state: {
+    contextMenuLinks: GuildContextMenuLinks;
+  };
+}
+
+export const contextMenuCmd = typedGuildCommand<ContextMenuPluginType>();
+export const contextMenuEvt = typedGuildEventListener<ContextMenuPluginType>();
diff --git a/backend/src/plugins/ContextMenus/utils/contextRouter.ts b/backend/src/plugins/ContextMenus/utils/contextRouter.ts
new file mode 100644
index 00000000..c3fd3abe
--- /dev/null
+++ b/backend/src/plugins/ContextMenus/utils/contextRouter.ts
@@ -0,0 +1,28 @@
+import { ContextMenuInteraction } from "discord.js";
+import { GuildPluginData } from "../../../../../../Knub/dist";
+import { availableActions } from "../actions/availableActions";
+import { ContextMenuPluginType } from "../types";
+
+export async function routeContextAction(
+  pluginData: GuildPluginData<ContextMenuPluginType>,
+  interaction: ContextMenuInteraction,
+) {
+  const contextLink = await pluginData.state.contextMenuLinks.get(interaction.commandId);
+  if (!contextLink) return;
+  const contextActions = Object.entries(pluginData.config.get().context_actions);
+
+  const configLink = contextActions.find(x => x[0] === contextLink.action_name);
+  if (!configLink) return;
+
+  for (const [actionName, actionConfig] of Object.entries(configLink[1].action)) {
+    if (actionConfig == null) return;
+    const action = availableActions[actionName];
+    action.apply({
+      actionName,
+      pluginData,
+      actionConfig,
+      interaction,
+    });
+    return;
+  }
+}
diff --git a/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts b/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts
new file mode 100644
index 00000000..a83cbd4a
--- /dev/null
+++ b/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts
@@ -0,0 +1,37 @@
+import { ApplicationCommandData } from "discord.js";
+import { LogType } from "src/data/LogType";
+import { LogsPlugin } from "src/plugins/Logs/LogsPlugin";
+import { GuildPluginData } from "../../../../../../Knub/dist";
+import { ContextMenuPluginType, ContextMenuTypeNameToNumber } from "../types";
+
+export async function loadAllCommands(pluginData: GuildPluginData<ContextMenuPluginType>) {
+  const comms = await pluginData.client.application!.commands;
+  const actions = pluginData.config.get().context_actions;
+  const newCommands: ApplicationCommandData[] = [];
+  const addedNames: string[] = [];
+
+  for (const [name, configAction] of Object.entries(actions)) {
+    if (!configAction.enabled) continue;
+
+    const data: ApplicationCommandData = {
+      type: ContextMenuTypeNameToNumber[configAction.type],
+      name: configAction.label,
+    };
+    addedNames.push(name);
+    newCommands.push(data);
+  }
+
+  const setCommands = await comms.set(newCommands, pluginData.guild.id).catch(e => {
+    pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, `Unable to overwrite context menus: ${e}`);
+    return undefined;
+  });
+  if (!setCommands) return;
+
+  const setCommandsArray = [...setCommands.values()];
+  await pluginData.state.contextMenuLinks.deleteAll();
+
+  for (let i = 0; i < setCommandsArray.length; i++) {
+    const command = setCommandsArray[i];
+    pluginData.state.contextMenuLinks.create(command.id, addedNames[i]);
+  }
+}
diff --git a/backend/src/plugins/ContextMenus/utils/resolveActionContactMethods.ts b/backend/src/plugins/ContextMenus/utils/resolveActionContactMethods.ts
new file mode 100644
index 00000000..bec64765
--- /dev/null
+++ b/backend/src/plugins/ContextMenus/utils/resolveActionContactMethods.ts
@@ -0,0 +1,32 @@
+import { Snowflake, TextChannel } from "discord.js";
+import { GuildPluginData } from "knub";
+import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
+import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils";
+import { ContextMenuPluginType } from "../types";
+
+export function resolveActionContactMethods(
+  pluginData: GuildPluginData<ContextMenuPluginType>,
+  actionConfig: {
+    notify?: string | null;
+    notifyChannel?: string | null;
+  },
+): UserNotificationMethod[] {
+  if (actionConfig.notify === "dm") {
+    return [{ type: "dm" }];
+  } else if (actionConfig.notify === "channel") {
+    if (!actionConfig.notifyChannel) {
+      throw new RecoverablePluginError(ERRORS.NO_USER_NOTIFICATION_CHANNEL);
+    }
+
+    const channel = pluginData.guild.channels.cache.get(actionConfig.notifyChannel as Snowflake);
+    if (!(channel instanceof TextChannel)) {
+      throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL);
+    }
+
+    return [{ type: "channel", channel }];
+  } else if (actionConfig.notify && disableUserNotificationStrings.includes(actionConfig.notify)) {
+    return [];
+  }
+
+  return [];
+}
diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts
index 2b7a7b41..a2e9a6e7 100644
--- a/backend/src/plugins/Utility/UtilityPlugin.ts
+++ b/backend/src/plugins/Utility/UtilityPlugin.ts
@@ -13,7 +13,7 @@ import { AboutCmd } from "./commands/AboutCmd";
 import { AvatarCmd } from "./commands/AvatarCmd";
 import { BanSearchCmd } from "./commands/BanSearchCmd";
 import { ChannelInfoCmd } from "./commands/ChannelInfoCmd";
-import { CleanCmd } from "./commands/CleanCmd";
+import { CleanArgs, cleanCmd, CleanCmd } from "./commands/CleanCmd";
 import { ContextCmd } from "./commands/ContextCmd";
 import { EmojiInfoCmd } from "./commands/EmojiInfoCmd";
 import { HelpCmd } from "./commands/HelpCmd";
@@ -156,6 +156,14 @@ export const UtilityPlugin = zeppelinGuildPlugin<UtilityPluginType>()({
     AutoJoinThreadSyncEvt,
   ],
 
+  public: {
+    clean(pluginData) {
+      return (args: CleanArgs, msg) => {
+        cleanCmd(pluginData, args, msg);
+      };
+    },
+  },
+
   beforeLoad(pluginData) {
     const { state, guild } = pluginData;
 
diff --git a/backend/src/plugins/Utility/commands/CleanCmd.ts b/backend/src/plugins/Utility/commands/CleanCmd.ts
index ca96d57f..c7f8e6c3 100644
--- a/backend/src/plugins/Utility/commands/CleanCmd.ts
+++ b/backend/src/plugins/Utility/commands/CleanCmd.ts
@@ -10,12 +10,13 @@ import { getBaseUrl, sendErrorMessage, sendSuccessMessage } from "../../../plugi
 import { allowTimeout } from "../../../RegExpRunner";
 import { DAYS, getInviteCodesInString, noop, SECONDS } from "../../../utils";
 import { utilityCmd, UtilityPluginType } from "../types";
+import { boolean, number } from "io-ts";
 
 const MAX_CLEAN_COUNT = 150;
 const MAX_CLEAN_TIME = 1 * DAYS;
 const CLEAN_COMMAND_DELETE_DELAY = 5 * SECONDS;
 
-async function cleanMessages(
+export async function cleanMessages(
   pluginData: GuildPluginData<UtilityPluginType>,
   channel: TextChannel,
   savedMessages: SavedMessage[],
@@ -61,6 +62,142 @@ const opts = {
   "to-id": ct.anyId({ option: true, shortcut: "id" }),
 };
 
+export interface CleanArgs {
+  count: number;
+  update?: boolean;
+  user?: string;
+  channel?: string;
+  bots?: boolean;
+  "delete-pins"?: boolean;
+  "has-invites"?: boolean;
+  match?: RegExp;
+  "to-id"?: string;
+}
+
+export async function cleanCmd(pluginData: GuildPluginData<UtilityPluginType>, args: CleanArgs | any, msg) {
+  if (args.count > MAX_CLEAN_COUNT || args.count <= 0) {
+    sendErrorMessage(pluginData, msg.channel, `Clean count must be between 1 and ${MAX_CLEAN_COUNT}`);
+    return;
+  }
+
+  const targetChannel = args.channel ? pluginData.guild.channels.cache.get(args.channel as Snowflake) : msg.channel;
+  if (!targetChannel || !(targetChannel instanceof TextChannel)) {
+    sendErrorMessage(pluginData, msg.channel, `Invalid channel specified`);
+    return;
+  }
+
+  if (targetChannel.id !== msg.channel.id) {
+    const configForTargetChannel = await pluginData.config.getMatchingConfig({
+      userId: msg.author.id,
+      member: msg.member,
+      channelId: targetChannel.id,
+      categoryId: targetChannel.parentId,
+    });
+    if (configForTargetChannel.can_clean !== true) {
+      sendErrorMessage(pluginData, msg.channel, `Missing permissions to use clean on that channel`);
+      return;
+    }
+  }
+
+  const cleaningMessage = msg.channel.send("Cleaning...");
+
+  const messagesToClean: SavedMessage[] = [];
+  let beforeId = msg.id;
+  const timeCutoff = msg.createdTimestamp - MAX_CLEAN_TIME;
+  const upToMsgId = args["to-id"];
+  let foundId = false;
+
+  const deletePins = args["delete-pins"] != null ? args["delete-pins"] : false;
+  let pins: Message[] = [];
+  if (!deletePins) {
+    pins = [...(await msg.channel.messages.fetchPinned().catch(() => [])).values()];
+  }
+
+  while (messagesToClean.length < args.count) {
+    const potentialMessages = await targetChannel.messages.fetch({
+      before: beforeId,
+      limit: args.count,
+    });
+    if (potentialMessages.size === 0) break;
+
+    const existingStored = await pluginData.state.savedMessages.getMultiple([...potentialMessages.keys()]);
+    const alreadyStored = existingStored.map(stored => stored.id);
+    const messagesToStore = [
+      ...potentialMessages.filter(potentialMsg => !alreadyStored.includes(potentialMsg.id)).values(),
+    ];
+    await pluginData.state.savedMessages.createFromMessages(messagesToStore);
+
+    const potentialMessagesToClean = await pluginData.state.savedMessages.getMultiple([...potentialMessages.keys()]);
+    if (potentialMessagesToClean.length === 0) break;
+
+    const filtered: SavedMessage[] = [];
+    for (const message of potentialMessagesToClean) {
+      const contentString = message.data.content || "";
+      if (args.user && message.user_id !== args.user) continue;
+      if (args.bots && !message.is_bot) continue;
+      if (!deletePins && pins.find(x => x.id === message.id) != null) continue;
+      if (args["has-invites"] && getInviteCodesInString(contentString).length === 0) continue;
+      if (upToMsgId != null && message.id < upToMsgId) {
+        foundId = true;
+        break;
+      }
+      if (moment.utc(message.posted_at).valueOf() < timeCutoff) continue;
+      if (args.match && !(await pluginData.state.regexRunner.exec(args.match, contentString).catch(allowTimeout))) {
+        continue;
+      }
+
+      filtered.push(message);
+    }
+    const remaining = args.count - messagesToClean.length;
+    const withoutOverflow = filtered.slice(0, remaining);
+    messagesToClean.push(...withoutOverflow);
+
+    beforeId = potentialMessages.lastKey()!;
+
+    if (foundId || moment.utc(potentialMessages.last()!.createdTimestamp).valueOf() < timeCutoff) {
+      break;
+    }
+  }
+
+  let responseMsg: Message | undefined;
+  if (messagesToClean.length > 0) {
+    const cleanResult = await cleanMessages(pluginData, targetChannel, messagesToClean, msg.author);
+
+    let responseText = `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`;
+    if (targetChannel.id !== msg.channel.id) {
+      responseText += ` in <#${targetChannel.id}>: ${cleanResult.archiveUrl}`;
+    }
+
+    if (args.update) {
+      const modActions = pluginData.getPlugin(ModActionsPlugin);
+      const channelId = targetChannel.id !== msg.channel.id ? targetChannel.id : msg.channel.id;
+      const updateMessage = `Cleaned ${messagesToClean.length} ${
+        messagesToClean.length === 1 ? "message" : "messages"
+      } in <#${channelId}>: ${cleanResult.archiveUrl}`;
+      if (typeof args.update === "number") {
+        modActions.updateCase(msg, args.update, updateMessage);
+      } else {
+        modActions.updateCase(msg, null, updateMessage);
+      }
+    }
+
+    responseMsg = await sendSuccessMessage(pluginData, msg.channel, responseText);
+  } else {
+    responseMsg = await sendErrorMessage(pluginData, msg.channel, `Found no messages to clean!`);
+  }
+
+  await (await cleaningMessage).delete();
+
+  if (targetChannel.id === msg.channel.id) {
+    // Delete the !clean command and the bot response if a different channel wasn't specified
+    // (so as not to spam the cleaned channel with the command itself)
+    setTimeout(() => {
+      msg.delete().catch(noop);
+      responseMsg?.delete().catch(noop);
+    }, CLEAN_COMMAND_DELETE_DELAY);
+  }
+}
+
 export const CleanCmd = utilityCmd({
   trigger: ["clean", "clear"],
   description: "Remove a number of recent messages",
@@ -83,126 +220,6 @@ export const CleanCmd = utilityCmd({
   ],
 
   async run({ message: msg, args, pluginData }) {
-    if (args.count > MAX_CLEAN_COUNT || args.count <= 0) {
-      sendErrorMessage(pluginData, msg.channel, `Clean count must be between 1 and ${MAX_CLEAN_COUNT}`);
-      return;
-    }
-
-    const targetChannel = args.channel ? pluginData.guild.channels.cache.get(args.channel as Snowflake) : msg.channel;
-    if (!targetChannel || !(targetChannel instanceof TextChannel)) {
-      sendErrorMessage(pluginData, msg.channel, `Invalid channel specified`);
-      return;
-    }
-
-    if (targetChannel.id !== msg.channel.id) {
-      const configForTargetChannel = await pluginData.config.getMatchingConfig({
-        userId: msg.author.id,
-        member: msg.member,
-        channelId: targetChannel.id,
-        categoryId: targetChannel.parentId,
-      });
-      if (configForTargetChannel.can_clean !== true) {
-        sendErrorMessage(pluginData, msg.channel, `Missing permissions to use clean on that channel`);
-        return;
-      }
-    }
-
-    const cleaningMessage = msg.channel.send("Cleaning...");
-
-    const messagesToClean: SavedMessage[] = [];
-    let beforeId = msg.id;
-    const timeCutoff = msg.createdTimestamp - MAX_CLEAN_TIME;
-    const upToMsgId = args["to-id"];
-    let foundId = false;
-
-    const deletePins = args["delete-pins"] != null ? args["delete-pins"] : false;
-    let pins: Message[] = [];
-    if (!deletePins) {
-      pins = [...(await msg.channel.messages.fetchPinned().catch(() => [])).values()];
-    }
-
-    while (messagesToClean.length < args.count) {
-      const potentialMessages = await targetChannel.messages.fetch({
-        before: beforeId,
-        limit: args.count,
-      });
-      if (potentialMessages.size === 0) break;
-
-      const existingStored = await pluginData.state.savedMessages.getMultiple([...potentialMessages.keys()]);
-      const alreadyStored = existingStored.map(stored => stored.id);
-      const messagesToStore = [
-        ...potentialMessages.filter(potentialMsg => !alreadyStored.includes(potentialMsg.id)).values(),
-      ];
-      await pluginData.state.savedMessages.createFromMessages(messagesToStore);
-
-      const potentialMessagesToClean = await pluginData.state.savedMessages.getMultiple([...potentialMessages.keys()]);
-      if (potentialMessagesToClean.length === 0) break;
-
-      const filtered: SavedMessage[] = [];
-      for (const message of potentialMessagesToClean) {
-        const contentString = message.data.content || "";
-        if (args.user && message.user_id !== args.user) continue;
-        if (args.bots && !message.is_bot) continue;
-        if (!deletePins && pins.find(x => x.id === message.id) != null) continue;
-        if (args["has-invites"] && getInviteCodesInString(contentString).length === 0) continue;
-        if (upToMsgId != null && message.id < upToMsgId) {
-          foundId = true;
-          break;
-        }
-        if (moment.utc(message.posted_at).valueOf() < timeCutoff) continue;
-        if (args.match && !(await pluginData.state.regexRunner.exec(args.match, contentString).catch(allowTimeout))) {
-          continue;
-        }
-
-        filtered.push(message);
-      }
-      const remaining = args.count - messagesToClean.length;
-      const withoutOverflow = filtered.slice(0, remaining);
-      messagesToClean.push(...withoutOverflow);
-
-      beforeId = potentialMessages.lastKey()!;
-
-      if (foundId || moment.utc(potentialMessages.last()!.createdTimestamp).valueOf() < timeCutoff) {
-        break;
-      }
-    }
-
-    let responseMsg: Message | undefined;
-    if (messagesToClean.length > 0) {
-      const cleanResult = await cleanMessages(pluginData, targetChannel, messagesToClean, msg.author);
-
-      let responseText = `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`;
-      if (targetChannel.id !== msg.channel.id) {
-        responseText += ` in <#${targetChannel.id}>: ${cleanResult.archiveUrl}`;
-      }
-
-      if (args.update) {
-        const modActions = pluginData.getPlugin(ModActionsPlugin);
-        const channelId = targetChannel.id !== msg.channel.id ? targetChannel.id : msg.channel.id;
-        const updateMessage = `Cleaned ${messagesToClean.length} ${
-          messagesToClean.length === 1 ? "message" : "messages"
-        } in <#${channelId}>: ${cleanResult.archiveUrl}`;
-        if (typeof args.update === "number") {
-          modActions.updateCase(msg, args.update, updateMessage);
-        } else {
-          modActions.updateCase(msg, null, updateMessage);
-        }
-      }
-
-      responseMsg = await sendSuccessMessage(pluginData, msg.channel, responseText);
-    } else {
-      responseMsg = await sendErrorMessage(pluginData, msg.channel, `Found no messages to clean!`);
-    }
-
-    await (await cleaningMessage).delete();
-
-    if (targetChannel.id === msg.channel.id) {
-      // Delete the !clean command and the bot response if a different channel wasn't specified
-      // (so as not to spam the cleaned channel with the command itself)
-      setTimeout(() => {
-        msg.delete().catch(noop);
-        responseMsg?.delete().catch(noop);
-      }, CLEAN_COMMAND_DELETE_DELAY);
-    }
+    cleanCmd(pluginData, args, msg);
   },
 });
diff --git a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
index d1788bce..5052ac11 100644
--- a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
@@ -134,9 +134,8 @@ export async function getChannelInfoEmbed(
     const memberCount = thread.memberCount ?? thread.members.cache.size;
     const owner = await thread.fetchOwner().catch(() => null);
     const ownerMention = owner?.user ? verboseUserMention(owner.user) : "Unknown#0000";
-    const humanizedArchiveTime = `Archive duration: **${humanizeDuration(
-      (thread.autoArchiveDuration ?? 0) * MINUTES,
-    )}**`;
+    const autoArchiveDuration = thread.autoArchiveDuration === "MAX" ? 10080 : thread.autoArchiveDuration; // TODO: Boost level check
+    const humanizedArchiveTime = `Archive duration: **${humanizeDuration((autoArchiveDuration ?? 0) * MINUTES)}**`;
 
     embed.fields.push({
       name: preEmbedPadding + "Thread information",
diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts
index 3f0568f6..34535b7c 100644
--- a/backend/src/plugins/availablePlugins.ts
+++ b/backend/src/plugins/availablePlugins.ts
@@ -6,6 +6,7 @@ import { CasesPlugin } from "./Cases/CasesPlugin";
 import { CensorPlugin } from "./Censor/CensorPlugin";
 import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin";
 import { CompanionChannelsPlugin } from "./CompanionChannels/CompanionChannelsPlugin";
+import { ContextMenuPlugin } from "./ContextMenus/ContextMenuPlugin";
 import { CountersPlugin } from "./Counters/CountersPlugin";
 import { CustomEventsPlugin } from "./CustomEvents/CustomEventsPlugin";
 import { GuildAccessMonitorPlugin } from "./GuildAccessMonitor/GuildAccessMonitorPlugin";
@@ -67,6 +68,7 @@ export const guildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [
   CustomEventsPlugin,
   TimeAndDatePlugin,
   CountersPlugin,
+  ContextMenuPlugin,
 ];
 
 // prettier-ignore