From 90ee4ad9095e87a0cea3b93e5d2ffccc1570c826 Mon Sep 17 00:00:00 2001
From: Dark <7890309+DarkView@users.noreply.github.com>
Date: Wed, 22 Jul 2020 22:33:10 +0200
Subject: [PATCH 1/3] Migrate ChannelArchiver to new Plugin structure, isOwner
 -> pluginUtils

---
 backend/src/pluginUtils.ts                    |  10 ++
 .../ChannelArchiver/ChannelArchiverPlugin.ts  |  16 +++
 .../commands/ArchiveChannelCmd.ts             | 110 ++++++++++++++++++
 .../ChannelArchiver/rehostAttachment.ts       |  29 +++++
 backend/src/plugins/ChannelArchiver/types.ts  |   7 ++
 backend/src/plugins/availablePlugins.ts       |   2 +
 6 files changed, 174 insertions(+)
 create mode 100644 backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts
 create mode 100644 backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts
 create mode 100644 backend/src/plugins/ChannelArchiver/rehostAttachment.ts
 create mode 100644 backend/src/plugins/ChannelArchiver/types.ts

diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts
index 89184e7e..d23f3d6d 100644
--- a/backend/src/pluginUtils.ts
+++ b/backend/src/pluginUtils.ts
@@ -66,3 +66,13 @@ export function getBaseUrl(pluginData: PluginData<any>) {
   const knub = pluginData.getKnubInstance() as TZeppelinKnub;
   return knub.getGlobalConfig().url;
 }
+
+export function isOwner(pluginData: PluginData<any>, userId: string) {
+  const knub = pluginData.getKnubInstance() as TZeppelinKnub;
+  const owners = knub.getGlobalConfig().owners;
+  if (!owners) {
+    return false;
+  }
+
+  return owners.includes(userId);
+}
diff --git a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts
new file mode 100644
index 00000000..599977db
--- /dev/null
+++ b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts
@@ -0,0 +1,16 @@
+import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
+import { ChannelArchiverPluginType } from "./types";
+import { ArchiveChannelCmd } from "./commands/ArchiveChannelCmd";
+
+export const ChannelArchiverPlugin = zeppelinPlugin<ChannelArchiverPluginType>()("channel_archiver", {
+  showInDocs: false,
+
+  // prettier-ignore
+  commands: [
+      ArchiveChannelCmd,
+  ],
+
+  onLoad(pluginData) {
+    const { state, guild } = pluginData;
+  },
+});
diff --git a/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts
new file mode 100644
index 00000000..61eff881
--- /dev/null
+++ b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts
@@ -0,0 +1,110 @@
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+import { channelArchiverCmd } from "../types";
+import { isOwner, sendErrorMessage } from "src/pluginUtils";
+import { confirm, SECONDS, noop } from "src/utils";
+import moment from "moment-timezone";
+import { rehostAttachment } from "../rehostAttachment";
+
+const MAX_ARCHIVED_MESSAGES = 5000;
+const MAX_MESSAGES_PER_FETCH = 100;
+const PROGRESS_UPDATE_INTERVAL = 5 * SECONDS;
+
+export const ArchiveChannelCmd = channelArchiverCmd({
+  trigger: "archive_channel",
+  permission: null,
+
+  config: {
+    preFilters: [
+      (command, context) => {
+        return isOwner(context.pluginData, context.message.author.id);
+      },
+    ],
+  },
+
+  signature: {
+    channel: ct.textChannel(),
+
+    "attachment-channel": ct.textChannel({ option: true }),
+    messages: ct.number({ option: true }),
+  },
+
+  async run({ message: msg, args, pluginData }) {
+    if (!args["attachment-channel"]) {
+      const confirmed = await confirm(
+        pluginData.client,
+        msg.channel,
+        msg.author.id,
+        "No `-attachment-channel` specified. Continue? Attachments will not be available in the log if their message is deleted.",
+      );
+      if (!confirmed) {
+        sendErrorMessage(pluginData, msg.channel, "Canceled");
+        return;
+      }
+    }
+
+    const maxMessagesToArchive = args.messages ? Math.min(args.messages, MAX_ARCHIVED_MESSAGES) : MAX_ARCHIVED_MESSAGES;
+    if (maxMessagesToArchive <= 0) return;
+
+    const archiveLines = [];
+    let archivedMessages = 0;
+    let previousId;
+
+    const startTime = Date.now();
+    const progressMsg = await msg.channel.createMessage("Creating archive...");
+    const progressUpdateInterval = setInterval(() => {
+      const secondsSinceStart = Math.round((Date.now() - startTime) / 1000);
+      progressMsg
+        .edit(`Creating archive...\n**Status:** ${archivedMessages} messages archived in ${secondsSinceStart} seconds`)
+        .catch(() => clearInterval(progressUpdateInterval));
+    }, PROGRESS_UPDATE_INTERVAL);
+
+    while (archivedMessages < maxMessagesToArchive) {
+      const messagesToFetch = Math.min(MAX_MESSAGES_PER_FETCH, maxMessagesToArchive - archivedMessages);
+      const messages = await args.channel.getMessages(messagesToFetch, previousId);
+      if (messages.length === 0) break;
+
+      for (const message of messages) {
+        const ts = moment.utc(message.timestamp).format("YYYY-MM-DD HH:mm:ss");
+        let content = `[${ts}] [${message.author.id}] [${message.author.username}#${
+          message.author.discriminator
+        }]: ${message.content || "<no text content>"}`;
+
+        if (message.attachments.length) {
+          if (args["attachment-channel"]) {
+            const rehostedAttachmentUrl = await rehostAttachment(message.attachments[0], args["attachment-channel"]);
+            content += `\n-- Attachment: ${rehostedAttachmentUrl}`;
+          } else {
+            content += `\n-- Attachment: ${message.attachments[0].url}`;
+          }
+        }
+
+        if (message.reactions && Object.keys(message.reactions).length > 0) {
+          const reactionCounts = [];
+          for (const [emoji, info] of Object.entries(message.reactions)) {
+            reactionCounts.push(`${info.count}x ${emoji}`);
+          }
+          content += `\n-- Reactions: ${reactionCounts.join(", ")}`;
+        }
+
+        archiveLines.push(content);
+        previousId = message.id;
+        archivedMessages++;
+      }
+    }
+
+    clearInterval(progressUpdateInterval);
+
+    archiveLines.reverse();
+
+    const nowTs = moment().format("YYYY-MM-DD HH:mm:ss");
+
+    let result = `Archived ${archiveLines.length} messages from #${args.channel.name} at ${nowTs}`;
+    result += `\n\n${archiveLines.join("\n")}\n`;
+
+    progressMsg.delete().catch(noop);
+    msg.channel.createMessage("Archive created!", {
+      file: Buffer.from(result),
+      name: `archive-${args.channel.name}-${moment().format("YYYY-MM-DD-HH-mm-ss")}.txt`,
+    });
+  },
+});
diff --git a/backend/src/plugins/ChannelArchiver/rehostAttachment.ts b/backend/src/plugins/ChannelArchiver/rehostAttachment.ts
new file mode 100644
index 00000000..0b12e360
--- /dev/null
+++ b/backend/src/plugins/ChannelArchiver/rehostAttachment.ts
@@ -0,0 +1,29 @@
+import { Attachment, TextChannel } from "eris";
+import { downloadFile } from "src/utils";
+import fs from "fs";
+const fsp = fs.promises;
+
+const MAX_ATTACHMENT_REHOST_SIZE = 1024 * 1024 * 8;
+
+export async function rehostAttachment(attachment: Attachment, targetChannel: TextChannel): Promise<string> {
+  if (attachment.size > MAX_ATTACHMENT_REHOST_SIZE) {
+    return "Attachment too big to rehost";
+  }
+
+  let downloaded;
+  try {
+    downloaded = await downloadFile(attachment.url, 3);
+  } catch (e) {
+    return "Failed to download attachment after 3 tries";
+  }
+
+  try {
+    const rehostMessage = await targetChannel.createMessage(`Rehost of attachment ${attachment.id}`, {
+      name: attachment.filename,
+      file: await fsp.readFile(downloaded.path),
+    });
+    return rehostMessage.attachments[0].url;
+  } catch (e) {
+    return "Failed to rehost attachment";
+  }
+}
diff --git a/backend/src/plugins/ChannelArchiver/types.ts b/backend/src/plugins/ChannelArchiver/types.ts
new file mode 100644
index 00000000..cbed3e52
--- /dev/null
+++ b/backend/src/plugins/ChannelArchiver/types.ts
@@ -0,0 +1,7 @@
+import { BasePluginType, command } from "knub";
+
+export interface ChannelArchiverPluginType extends BasePluginType {
+  state: {};
+}
+
+export const channelArchiverCmd = command<ChannelArchiverPluginType>();
diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts
index 32825725..e09b2b32 100644
--- a/backend/src/plugins/availablePlugins.ts
+++ b/backend/src/plugins/availablePlugins.ts
@@ -13,10 +13,12 @@ import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigRelo
 import { CasesPlugin } from "./Cases/CasesPlugin";
 import { MutesPlugin } from "./Mutes/MutesPlugin";
 import { TagsPlugin } from "./Tags/TagsPlugin";
+import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin";
 
 // prettier-ignore
 export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
   AutoReactionsPlugin,
+  ChannelArchiverPlugin,
   LocateUserPlugin,
   PersistPlugin,
   PingableRolesPlugin,

From 599a504b1752138ff90ff12e38e9b5720b23d48e Mon Sep 17 00:00:00 2001
From: Dark <7890309+DarkView@users.noreply.github.com>
Date: Wed, 22 Jul 2020 23:15:40 +0200
Subject: [PATCH 2/3] Migrate Starboard to new Plugin structure

---
 .../src/plugins/Starboard/StarboardPlugin.ts  | 118 ++++++++++++++++++
 .../Starboard/commands/MigratePinsCmd.ts      |  52 ++++++++
 .../events/StarboardReactionAddEvt.ts         |  74 +++++++++++
 .../events/StarboardReactionRemoveEvts.ts     |  17 +++
 backend/src/plugins/Starboard/types.ts        |  43 +++++++
 .../getStarboardOptsForStarboardChannel.ts    |  19 +++
 .../plugins/Starboard/util/onMessageDelete.ts |  27 ++++
 .../Starboard/util/preprocessStaticConfig.ts  |  12 ++
 .../util/removeMessageFromStarboard.ts        |   6 +
 .../removeMessageFromStarboardMessages.ts     |   3 +
 .../Starboard/util/saveMessageToStarboard.ts  |  70 +++++++++++
 backend/src/plugins/availablePlugins.ts       |   2 +
 12 files changed, 443 insertions(+)
 create mode 100644 backend/src/plugins/Starboard/StarboardPlugin.ts
 create mode 100644 backend/src/plugins/Starboard/commands/MigratePinsCmd.ts
 create mode 100644 backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts
 create mode 100644 backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts
 create mode 100644 backend/src/plugins/Starboard/types.ts
 create mode 100644 backend/src/plugins/Starboard/util/getStarboardOptsForStarboardChannel.ts
 create mode 100644 backend/src/plugins/Starboard/util/onMessageDelete.ts
 create mode 100644 backend/src/plugins/Starboard/util/preprocessStaticConfig.ts
 create mode 100644 backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts
 create mode 100644 backend/src/plugins/Starboard/util/removeMessageFromStarboardMessages.ts
 create mode 100644 backend/src/plugins/Starboard/util/saveMessageToStarboard.ts

diff --git a/backend/src/plugins/Starboard/StarboardPlugin.ts b/backend/src/plugins/Starboard/StarboardPlugin.ts
new file mode 100644
index 00000000..5d251077
--- /dev/null
+++ b/backend/src/plugins/Starboard/StarboardPlugin.ts
@@ -0,0 +1,118 @@
+import { PluginOptions } from "knub";
+import { ConfigSchema, StarboardPluginType } from "./types";
+import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
+import { trimPluginDescription } from "src/utils";
+import { GuildSavedMessages } from "src/data/GuildSavedMessages";
+import { GuildStarboardMessages } from "src/data/GuildStarboardMessages";
+import { GuildStarboardReactions } from "src/data/GuildStarboardReactions";
+import { onMessageDelete } from "./util/onMessageDelete";
+import { MigratePinsCmd } from "./commands/MigratePinsCmd";
+import { StarboardReactionAddEvt } from "./events/StarboardReactionAddEvt";
+import { StarboardReactionRemoveEvt, StarboardReactionRemoveAllEvt } from "./events/StarboardReactionRemoveEvts";
+
+const defaultOptions: PluginOptions<StarboardPluginType> = {
+  config: {
+    can_migrate: false,
+    boards: {},
+  },
+
+  overrides: [
+    {
+      level: ">=100",
+      config: {
+        can_migrate: true,
+      },
+    },
+  ],
+};
+
+export const StarboardPlugin = zeppelinPlugin<StarboardPluginType>()("starboard", {
+  configSchema: ConfigSchema,
+  defaultOptions,
+
+  info: {
+    prettyName: "Starboard",
+    description: trimPluginDescription(`
+      This plugin allows you to set up starboards on your server. Starboards are like user voted pins where messages with enough reactions get immortalized on a "starboard" channel.
+    `),
+    configurationGuide: trimPluginDescription(`
+      ### Note on emojis
+      To specify emoji in the config, you need to use the emoji's "raw form".
+      To obtain this, post the emoji with a backslash in front of it.
+      
+      - Example with a default emoji: "\:star:" => "⭐"
+      - Example with a custom emoji: "\:mrvnSmile:" => "<:mrvnSmile:543000534102310933>"
+
+      ### Basic starboard
+      Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226).
+      
+      ~~~yml
+      starboard:
+        config:
+          boards:
+            basic:
+              channel_id: "604342689038729226"
+              stars_required: 5
+      ~~~
+      
+      ### Custom star emoji
+      This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji
+      
+      ~~~yml
+      starboard:
+        config:
+          boards:
+            basic:
+              channel_id: "604342689038729226"
+              star_emoji: ["⭐", "<:mrvnSmile:543000534102310933>"]
+              stars_required: 5
+      ~~~
+      
+      ### Limit starboard to a specific channel
+      This is identical to the basic starboard above, but only works from a specific channel (473087035574321152).
+      
+      ~~~yml
+      starboard:
+        config:
+          boards:
+            basic:
+              enabled: false # The starboard starts disabled and is then enabled in a channel override below
+              channel_id: "604342689038729226"
+              stars_required: 5
+        overrides:
+          - channel: "473087035574321152"
+            config:
+              boards:
+                basic:
+                  enabled: true
+      ~~~
+    `),
+  },
+
+  // prettier-ignore
+  commands: [
+      MigratePinsCmd,
+  ],
+
+  // prettier-ignore
+  events: [
+      StarboardReactionAddEvt,
+      StarboardReactionRemoveEvt,
+      StarboardReactionRemoveAllEvt,
+  ],
+
+  onLoad(pluginData) {
+    const { state, guild } = pluginData;
+
+    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);
+    state.starboardMessages = GuildStarboardMessages.getGuildInstance(guild.id);
+    state.starboardReactions = GuildStarboardReactions.getGuildInstance(guild.id);
+
+    state.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg);
+    state.savedMessages.events.on("delete", state.onMessageDeleteFn);
+  },
+
+  onUnload(pluginData) {
+    pluginData.state.savedMessages.events.off("delete", pluginData.state.onMessageDeleteFn);
+  },
+});
diff --git a/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts b/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts
new file mode 100644
index 00000000..e37155c3
--- /dev/null
+++ b/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts
@@ -0,0 +1,52 @@
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+import { starboardCmd } from "../types";
+import { sendSuccessMessage, sendErrorMessage } from "src/pluginUtils";
+import { TextChannel } from "eris";
+import { saveMessageToStarboard } from "../util/saveMessageToStarboard";
+
+export const MigratePinsCmd = starboardCmd({
+  trigger: "starboard migrate_pins",
+  permission: "can_migrate",
+
+  description: "Posts all pins from a channel to the specified starboard. The pins are NOT unpinned automatically.",
+
+  signature: {
+    pinChannel: ct.textChannel(),
+    starboardName: ct.string(),
+  },
+
+  async run({ message: msg, args, pluginData }) {
+    const config = await pluginData.config.get();
+    const starboard = config.boards[args.starboardName];
+    if (!starboard) {
+      sendErrorMessage(pluginData, msg.channel, "Unknown starboard specified");
+      return;
+    }
+
+    const starboardChannel = pluginData.guild.channels.get(starboard.channel_id);
+    if (!starboardChannel || !(starboardChannel instanceof TextChannel)) {
+      sendErrorMessage(pluginData, msg.channel, "Starboard has an unknown/invalid channel id");
+      return;
+    }
+
+    msg.channel.createMessage(`Migrating pins from <#${args.pinChannel.id}> to <#${starboardChannel.id}>...`);
+
+    const pins = await args.pinChannel.getPins();
+    pins.reverse(); // Migrate pins starting from the oldest message
+
+    for (const pin of pins) {
+      const existingStarboardMessage = await pluginData.state.starboardMessages.getMatchingStarboardMessages(
+        starboardChannel.id,
+        pin.id,
+      );
+      if (existingStarboardMessage.length > 0) continue;
+      await saveMessageToStarboard(pluginData, pin, starboard);
+    }
+
+    sendSuccessMessage(
+      pluginData,
+      msg.channel,
+      `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`,
+    );
+  },
+});
diff --git a/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts b/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts
new file mode 100644
index 00000000..1f8aef28
--- /dev/null
+++ b/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts
@@ -0,0 +1,74 @@
+import { starboardEvt } from "../types";
+import { Message } from "eris";
+import { UnknownUser, resolveMember, noop } from "src/utils";
+import { saveMessageToStarboard } from "../util/saveMessageToStarboard";
+
+export const StarboardReactionAddEvt = starboardEvt({
+  event: "messageReactionAdd",
+
+  async listener(meta) {
+    const pluginData = meta.pluginData;
+
+    let msg = meta.args.message as Message;
+    const userId = meta.args.userID;
+    const emoji = meta.args.emoji;
+
+    if (!msg.author) {
+      // Message is not cached, fetch it
+      try {
+        msg = await msg.channel.getMessage(msg.id);
+      } catch (e) {
+        // Sometimes we get this event for messages we can't fetch with getMessage; ignore silently
+        return;
+      }
+    }
+
+    // No self-votes!
+    if (msg.author.id === userId) return;
+
+    const user = await resolveMember(pluginData.client, pluginData.guild, userId);
+    if (user instanceof UnknownUser) return;
+    if (user.bot) return;
+
+    const config = pluginData.config.getMatchingConfig({ member: user, channelId: msg.channel.id });
+    const applicableStarboards = Object.values(config.boards)
+      .filter(board => board.enabled)
+      // Can't star messages in the starboard channel itself
+      .filter(board => board.channel_id !== msg.channel.id)
+      // Matching emoji
+      .filter(board => {
+        return board.star_emoji.some((boardEmoji: string) => {
+          if (emoji.id) {
+            // Custom emoji
+            const customEmojiMatch = boardEmoji.match(/^<?:.+?:(\d+)>?$/);
+            if (customEmojiMatch) {
+              return customEmojiMatch[1] === emoji.id;
+            }
+
+            return boardEmoji === emoji.id;
+          } else {
+            // Unicode emoji
+            return emoji.name === boardEmoji;
+          }
+        });
+      });
+
+    for (const starboard of applicableStarboards) {
+      // Save reaction into the database
+      await pluginData.state.starboardReactions.createStarboardReaction(msg.id, userId).catch(noop);
+
+      // If the message has already been posted to this starboard, we don't need to do anything else
+      const starboardMessages = await pluginData.state.starboardMessages.getMatchingStarboardMessages(
+        starboard.channel_id,
+        msg.id,
+      );
+      if (starboardMessages.length > 0) continue;
+
+      const reactions = await pluginData.state.starboardReactions.getAllReactionsForMessageId(msg.id);
+      const reactionsCount = reactions.length;
+      if (reactionsCount >= starboard.stars_required) {
+        await saveMessageToStarboard(pluginData, msg, starboard);
+      }
+    }
+  },
+});
diff --git a/backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts b/backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts
new file mode 100644
index 00000000..5e15ab90
--- /dev/null
+++ b/backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts
@@ -0,0 +1,17 @@
+import { starboardEvt } from "../types";
+
+export const StarboardReactionRemoveEvt = starboardEvt({
+  event: "messageReactionRemove",
+
+  async listener(meta) {
+    await meta.pluginData.state.starboardReactions.deleteStarboardReaction(meta.args.message.id, meta.args.userID);
+  },
+});
+
+export const StarboardReactionRemoveAllEvt = starboardEvt({
+  event: "messageReactionRemoveAll",
+
+  async listener(meta) {
+    await meta.pluginData.state.starboardReactions.deleteAllStarboardReactionsForMessageId(meta.args.message.id);
+  },
+});
diff --git a/backend/src/plugins/Starboard/types.ts b/backend/src/plugins/Starboard/types.ts
new file mode 100644
index 00000000..4f382ab8
--- /dev/null
+++ b/backend/src/plugins/Starboard/types.ts
@@ -0,0 +1,43 @@
+import * as t from "io-ts";
+import { BasePluginType, command, eventListener } from "knub";
+import { tNullable, tDeepPartial } from "src/utils";
+import { GuildSavedMessages } from "src/data/GuildSavedMessages";
+import { GuildStarboardMessages } from "src/data/GuildStarboardMessages";
+import { GuildStarboardReactions } from "src/data/GuildStarboardReactions";
+
+const StarboardOpts = t.type({
+  channel_id: t.string,
+  stars_required: t.number,
+  star_emoji: tNullable(t.array(t.string)),
+  copy_full_embed: tNullable(t.boolean),
+  enabled: tNullable(t.boolean),
+});
+export type TStarboardOpts = t.TypeOf<typeof StarboardOpts>;
+
+export const ConfigSchema = t.type({
+  boards: t.record(t.string, StarboardOpts),
+  can_migrate: t.boolean,
+});
+export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+
+export const PartialConfigSchema = tDeepPartial(ConfigSchema);
+
+export const defaultStarboardOpts: Partial<TStarboardOpts> = {
+  star_emoji: ["⭐"],
+  enabled: true,
+};
+
+export interface StarboardPluginType extends BasePluginType {
+  config: TConfigSchema;
+
+  state: {
+    savedMessages: GuildSavedMessages;
+    starboardMessages: GuildStarboardMessages;
+    starboardReactions: GuildStarboardReactions;
+
+    onMessageDeleteFn;
+  };
+}
+
+export const starboardCmd = command<StarboardPluginType>();
+export const starboardEvt = eventListener<StarboardPluginType>();
diff --git a/backend/src/plugins/Starboard/util/getStarboardOptsForStarboardChannel.ts b/backend/src/plugins/Starboard/util/getStarboardOptsForStarboardChannel.ts
new file mode 100644
index 00000000..1c32b42e
--- /dev/null
+++ b/backend/src/plugins/Starboard/util/getStarboardOptsForStarboardChannel.ts
@@ -0,0 +1,19 @@
+import { TStarboardOpts, StarboardPluginType, defaultStarboardOpts } from "../types";
+import { PluginData } from "knub";
+
+export function getStarboardOptsForStarboardChannel(
+  pluginData: PluginData<StarboardPluginType>,
+  starboardChannel,
+): TStarboardOpts[] {
+  const config = pluginData.config.getForChannel(starboardChannel);
+
+  const configs = Object.values(config.boards).filter(opts => opts.channel_id === starboardChannel.id);
+  configs.forEach(cfg => {
+    if (cfg.enabled == null) cfg.enabled = defaultStarboardOpts.enabled;
+    if (cfg.star_emoji == null) cfg.star_emoji = defaultStarboardOpts.star_emoji;
+    if (cfg.stars_required == null) cfg.stars_required = defaultStarboardOpts.stars_required;
+    if (cfg.copy_full_embed == null) cfg.copy_full_embed = false;
+  });
+
+  return configs;
+}
diff --git a/backend/src/plugins/Starboard/util/onMessageDelete.ts b/backend/src/plugins/Starboard/util/onMessageDelete.ts
new file mode 100644
index 00000000..c79e3da8
--- /dev/null
+++ b/backend/src/plugins/Starboard/util/onMessageDelete.ts
@@ -0,0 +1,27 @@
+import { SavedMessage } from "src/data/entities/SavedMessage";
+import { PluginData } from "knub";
+import { StarboardPluginType } from "../types";
+import { removeMessageFromStarboard } from "./removeMessageFromStarboard";
+import { removeMessageFromStarboardMessages } from "./removeMessageFromStarboardMessages";
+
+export async function onMessageDelete(pluginData: PluginData<StarboardPluginType>, msg: SavedMessage) {
+  // Deleted source message
+  const starboardMessages = await pluginData.state.starboardMessages.getStarboardMessagesForMessageId(msg.id);
+  for (const starboardMessage of starboardMessages) {
+    removeMessageFromStarboard(pluginData, starboardMessage);
+  }
+
+  // Deleted message from the starboard
+  const deletedStarboardMessages = await pluginData.state.starboardMessages.getStarboardMessagesForStarboardMessageId(
+    msg.id,
+  );
+  if (deletedStarboardMessages.length === 0) return;
+
+  for (const starboardMessage of deletedStarboardMessages) {
+    removeMessageFromStarboardMessages(
+      pluginData,
+      starboardMessage.starboard_message_id,
+      starboardMessage.starboard_channel_id,
+    );
+  }
+}
diff --git a/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts b/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts
new file mode 100644
index 00000000..b690fbf8
--- /dev/null
+++ b/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts
@@ -0,0 +1,12 @@
+import { PartialConfigSchema, defaultStarboardOpts } from "../types";
+import * as t from "io-ts";
+
+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, config.boards[name]);
+    }
+  }
+
+  return config;
+}
diff --git a/backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts b/backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts
new file mode 100644
index 00000000..6c790b5d
--- /dev/null
+++ b/backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts
@@ -0,0 +1,6 @@
+import { StarboardMessage } from "src/data/entities/StarboardMessage";
+import { noop } from "src/utils";
+
+export async function removeMessageFromStarboard(pluginData, msg: StarboardMessage) {
+  await pluginData.client.deleteMessage(msg.starboard_channel_id, msg.starboard_message_id).catch(noop);
+}
diff --git a/backend/src/plugins/Starboard/util/removeMessageFromStarboardMessages.ts b/backend/src/plugins/Starboard/util/removeMessageFromStarboardMessages.ts
new file mode 100644
index 00000000..1d00177d
--- /dev/null
+++ b/backend/src/plugins/Starboard/util/removeMessageFromStarboardMessages.ts
@@ -0,0 +1,3 @@
+export async function removeMessageFromStarboardMessages(pluginData, starboard_message_id: string, channel_id: string) {
+  await pluginData.state.starboardMessages.deleteStarboardMessage(starboard_message_id, channel_id);
+}
diff --git a/backend/src/plugins/Starboard/util/saveMessageToStarboard.ts b/backend/src/plugins/Starboard/util/saveMessageToStarboard.ts
new file mode 100644
index 00000000..c060f8b5
--- /dev/null
+++ b/backend/src/plugins/Starboard/util/saveMessageToStarboard.ts
@@ -0,0 +1,70 @@
+import { PluginData } from "knub";
+import { StarboardPluginType, TStarboardOpts } from "../types";
+import { Message, GuildChannel, TextChannel, Embed } from "eris";
+import moment from "moment-timezone";
+import { EMPTY_CHAR, messageLink } from "src/utils";
+import path from "path";
+
+export async function saveMessageToStarboard(
+  pluginData: PluginData<StarboardPluginType>,
+  msg: Message,
+  starboard: TStarboardOpts,
+) {
+  const channel = pluginData.guild.channels.get(starboard.channel_id);
+  if (!channel) return;
+
+  const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]");
+
+  const embed: Embed = {
+    footer: {
+      text: `#${(msg.channel as GuildChannel).name}`,
+    },
+    author: {
+      name: `${msg.author.username}#${msg.author.discriminator}`,
+    },
+    fields: [],
+    timestamp: new Date(msg.timestamp).toISOString(),
+    type: "rich",
+  };
+
+  if (msg.author.avatarURL) {
+    embed.author.icon_url = msg.author.avatarURL;
+  }
+
+  if (msg.content) {
+    embed.description = msg.content;
+  }
+
+  // Merge media and - if copy_full_embed is enabled - fields and title from the first embed in the original message
+  if (msg.embeds.length > 0) {
+    if (msg.embeds[0].image) embed.image = msg.embeds[0].image;
+
+    if (starboard.copy_full_embed) {
+      if (msg.embeds[0].title) {
+        const titleText = msg.embeds[0].url ? `[${msg.embeds[0].title}](${msg.embeds[0].url})` : msg.embeds[0].title;
+        embed.fields.push({ name: EMPTY_CHAR, value: titleText });
+      }
+
+      if (msg.embeds[0].fields) embed.fields.push(...msg.embeds[0].fields);
+    }
+  }
+
+  // If there are no embeds, add the first image attachment explicitly
+  else if (msg.attachments.length) {
+    for (const attachment of msg.attachments) {
+      const ext = path
+        .extname(attachment.filename)
+        .slice(1)
+        .toLowerCase();
+      if (!["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) continue;
+
+      embed.image = { url: attachment.url };
+      break;
+    }
+  }
+
+  embed.fields.push({ name: EMPTY_CHAR, value: `[Jump to message](${messageLink(msg)})` });
+
+  const starboardMessage = await (channel as TextChannel).createMessage({ embed });
+  await pluginData.state.starboardMessages.createStarboardMessage(channel.id, msg.id, starboardMessage.id);
+}
diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts
index 32825725..06042b89 100644
--- a/backend/src/plugins/availablePlugins.ts
+++ b/backend/src/plugins/availablePlugins.ts
@@ -13,6 +13,7 @@ import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigRelo
 import { CasesPlugin } from "./Cases/CasesPlugin";
 import { MutesPlugin } from "./Mutes/MutesPlugin";
 import { TagsPlugin } from "./Tags/TagsPlugin";
+import { StarboardPlugin } from "./Starboard/StarboardPlugin";
 
 // prettier-ignore
 export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
@@ -23,6 +24,7 @@ export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
   MessageSaverPlugin,
   NameHistoryPlugin,
   RemindersPlugin,
+  StarboardPlugin,
   TagsPlugin,
   UsernameSaverPlugin,
   UtilityPlugin,

From b4b8680431cee59a0be08cd347bc2010069d40f4 Mon Sep 17 00:00:00 2001
From: Dark <7890309+DarkView@users.noreply.github.com>
Date: Thu, 23 Jul 2020 00:25:40 +0200
Subject: [PATCH 3/3] Migrate Slowmode to new Plugin structure

---
 .../src/plugins/Slowmode/SlowmodePlugin.ts    | 65 +++++++++++++
 .../Slowmode/commands/SlowmodeClearCmd.ts     | 40 ++++++++
 .../Slowmode/commands/SlowmodeDisableCmd.ts   | 20 ++++
 .../commands/SlowmodeGetChannelCmd.ts         | 37 ++++++++
 .../Slowmode/commands/SlowmodeListCmd.ts      | 46 +++++++++
 .../commands/SlowmodeSetChannelCmd.ts         | 93 +++++++++++++++++++
 backend/src/plugins/Slowmode/types.ts         | 28 ++++++
 .../Slowmode/util/actualDisableSlowmodeCmd.ts | 39 ++++++++
 .../Slowmode/util/applyBotSlowmodeToUserId.ts | 43 +++++++++
 .../util/clearBotSlowmodeFromUserId.ts        | 24 +++++
 .../Slowmode/util/clearExpiredSlowmodes.ts    | 31 +++++++
 .../util/disableBotSlowmodeForChannel.ts      | 28 ++++++
 .../plugins/Slowmode/util/onMessageCreate.ts  | 42 +++++++++
 backend/src/plugins/availablePlugins.ts       |  2 +
 14 files changed, 538 insertions(+)
 create mode 100644 backend/src/plugins/Slowmode/SlowmodePlugin.ts
 create mode 100644 backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts
 create mode 100644 backend/src/plugins/Slowmode/commands/SlowmodeDisableCmd.ts
 create mode 100644 backend/src/plugins/Slowmode/commands/SlowmodeGetChannelCmd.ts
 create mode 100644 backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts
 create mode 100644 backend/src/plugins/Slowmode/commands/SlowmodeSetChannelCmd.ts
 create mode 100644 backend/src/plugins/Slowmode/types.ts
 create mode 100644 backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts
 create mode 100644 backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts
 create mode 100644 backend/src/plugins/Slowmode/util/clearBotSlowmodeFromUserId.ts
 create mode 100644 backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts
 create mode 100644 backend/src/plugins/Slowmode/util/disableBotSlowmodeForChannel.ts
 create mode 100644 backend/src/plugins/Slowmode/util/onMessageCreate.ts

diff --git a/backend/src/plugins/Slowmode/SlowmodePlugin.ts b/backend/src/plugins/Slowmode/SlowmodePlugin.ts
new file mode 100644
index 00000000..91154385
--- /dev/null
+++ b/backend/src/plugins/Slowmode/SlowmodePlugin.ts
@@ -0,0 +1,65 @@
+import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
+import { PluginOptions } from "knub";
+import { SlowmodePluginType, ConfigSchema } from "./types";
+import { GuildSlowmodes } from "src/data/GuildSlowmodes";
+import { GuildSavedMessages } from "src/data/GuildSavedMessages";
+import { GuildLogs } from "src/data/GuildLogs";
+import { SECONDS } from "src/utils";
+import { onMessageCreate } from "./util/onMessageCreate";
+import { clearExpiredSlowmodes } from "./util/clearExpiredSlowmodes";
+import { SlowmodeDisableCmd } from "./commands/SlowmodeDisableCmd";
+import { SlowmodeClearCmd } from "./commands/SlowmodeClearCmd";
+import { SlowmodeListCmd } from "./commands/SlowmodeListCmd";
+import { SlowmodeGetChannelCmd } from "./commands/SlowmodeGetChannelCmd";
+import { SlowmodeSetChannelCmd } from "./commands/SlowmodeSetChannelCmd";
+
+const BOT_SLOWMODE_CLEAR_INTERVAL = 60 * SECONDS;
+
+const defaultOptions: PluginOptions<SlowmodePluginType> = {
+  config: {
+    use_native_slowmode: true,
+
+    can_manage: false,
+    is_affected: true,
+  },
+
+  overrides: [
+    {
+      level: ">=50",
+      config: {
+        can_manage: true,
+        is_affected: false,
+      },
+    },
+  ],
+};
+
+export const SlowmodePlugin = zeppelinPlugin<SlowmodePluginType>()("slowmode", {
+  configSchema: ConfigSchema,
+  defaultOptions,
+
+  // prettier-ignore
+  commands: [
+    SlowmodeDisableCmd,
+    SlowmodeClearCmd,
+    SlowmodeListCmd,
+    SlowmodeGetChannelCmd,
+    SlowmodeSetChannelCmd,
+  ],
+
+  onLoad(pluginData) {
+    const { state, guild } = pluginData;
+
+    state.slowmodes = GuildSlowmodes.getGuildInstance(guild.id);
+    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);
+    state.logs = new GuildLogs(guild.id);
+    state.clearInterval = setInterval(() => clearExpiredSlowmodes(pluginData), BOT_SLOWMODE_CLEAR_INTERVAL);
+
+    state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg);
+    state.savedMessages.events.on("create", state.onMessageCreateFn);
+  },
+
+  onUnload(pluginData) {
+    pluginData.state.savedMessages.events.off("create", pluginData.state.onMessageCreateFn);
+  },
+});
diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts
new file mode 100644
index 00000000..a5806b41
--- /dev/null
+++ b/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts
@@ -0,0 +1,40 @@
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
+import { slowmodeCmd } from "../types";
+import { clearBotSlowmodeFromUserId } from "../util/clearBotSlowmodeFromUserId";
+
+export const SlowmodeClearCmd = slowmodeCmd({
+  trigger: ["slowmode clear", "slowmode c"],
+  permission: "can_manage",
+
+  signature: {
+    channel: ct.textChannel(),
+    user: ct.resolvedUserLoose(),
+
+    force: ct.bool({ option: true, isSwitch: true }),
+  },
+
+  async run({ message: msg, args, pluginData }) {
+    const channelSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(args.channel.id);
+    if (!channelSlowmode) {
+      sendErrorMessage(pluginData, msg.channel, "Channel doesn't have slowmode!");
+      return;
+    }
+
+    try {
+      await clearBotSlowmodeFromUserId(pluginData, args.channel, args.user.id, args.force);
+    } catch (e) {
+      return sendErrorMessage(
+        pluginData,
+        msg.channel,
+        `Failed to clear slowmode from **${args.user.username}#${args.user.discriminator}** in <#${args.channel.id}>`,
+      );
+    }
+
+    sendSuccessMessage(
+      pluginData,
+      msg.channel,
+      `Slowmode cleared from **${args.user.username}#${args.user.discriminator}** in <#${args.channel.id}>`,
+    );
+  },
+});
diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeDisableCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeDisableCmd.ts
new file mode 100644
index 00000000..20294f2d
--- /dev/null
+++ b/backend/src/plugins/Slowmode/commands/SlowmodeDisableCmd.ts
@@ -0,0 +1,20 @@
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
+import { slowmodeCmd } from "../types";
+import { disableBotSlowmodeForChannel } from "../util/disableBotSlowmodeForChannel";
+import { noop } from "src/utils";
+import { actualDisableSlowmodeCmd } from "../util/actualDisableSlowmodeCmd";
+
+export const SlowmodeDisableCmd = slowmodeCmd({
+  trigger: ["slowmode disable", "slowmode d"],
+  permission: "can_manage",
+
+  signature: {
+    channel: ct.textChannel(),
+  },
+
+  async run({ message: msg, args, pluginData }) {
+    // Workaround until you can call this cmd from SlowmodeSetChannelCmd
+    actualDisableSlowmodeCmd(msg, args, pluginData);
+  },
+});
diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeGetChannelCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeGetChannelCmd.ts
new file mode 100644
index 00000000..589f1373
--- /dev/null
+++ b/backend/src/plugins/Slowmode/commands/SlowmodeGetChannelCmd.ts
@@ -0,0 +1,37 @@
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+import { slowmodeCmd } from "../types";
+import { TextChannel } from "eris";
+import humanizeDuration from "humanize-duration";
+
+export const SlowmodeGetChannelCmd = slowmodeCmd({
+  trigger: "slowmode",
+  permission: "can_manage",
+  source: "guild",
+
+  signature: {
+    channel: ct.textChannel({ option: true }),
+  },
+
+  async run({ message: msg, args, pluginData }) {
+    const channel = args.channel || (msg.channel as TextChannel);
+
+    let currentSlowmode = channel.rateLimitPerUser;
+    let isNative = true;
+
+    if (!currentSlowmode) {
+      const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id);
+      if (botSlowmode) {
+        currentSlowmode = botSlowmode.slowmode_seconds;
+        isNative = false;
+      }
+    }
+
+    if (currentSlowmode) {
+      const humanized = humanizeDuration(channel.rateLimitPerUser * 1000);
+      const slowmodeType = isNative ? "native" : "bot-maintained";
+      msg.channel.createMessage(`The current slowmode of <#${channel.id}> is **${humanized}** (${slowmodeType})`);
+    } else {
+      msg.channel.createMessage("Channel is not on slowmode");
+    }
+  },
+});
diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts
new file mode 100644
index 00000000..422cb503
--- /dev/null
+++ b/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts
@@ -0,0 +1,46 @@
+import { slowmodeCmd } from "../types";
+import { GuildChannel, TextChannel } from "eris";
+import { createChunkedMessage } from "knub/dist/helpers";
+import { errorMessage } from "src/utils";
+import humanizeDuration from "humanize-duration";
+
+export const SlowmodeListCmd = slowmodeCmd({
+  trigger: ["slowmode list", "slowmode l", "slowmodes"],
+  permission: "can_manage",
+
+  async run({ message: msg, pluginData }) {
+    const channels = pluginData.guild.channels;
+    const slowmodes: Array<{ channel: GuildChannel; seconds: number; native: boolean }> = [];
+
+    for (const channel of channels.values()) {
+      if (!(channel instanceof TextChannel)) continue;
+
+      // Bot slowmode
+      const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id);
+      if (botSlowmode) {
+        slowmodes.push({ channel, seconds: botSlowmode.slowmode_seconds, native: false });
+        continue;
+      }
+
+      // Native slowmode
+      if (channel.rateLimitPerUser) {
+        slowmodes.push({ channel, seconds: channel.rateLimitPerUser, native: true });
+        continue;
+      }
+    }
+
+    if (slowmodes.length) {
+      const lines = slowmodes.map(slowmode => {
+        const humanized = humanizeDuration(slowmode.seconds * 1000);
+
+        const type = slowmode.native ? "native slowmode" : "bot slowmode";
+
+        return `<#${slowmode.channel.id}> **${humanized}** ${type}`;
+      });
+
+      createChunkedMessage(msg.channel, lines.join("\n"));
+    } else {
+      msg.channel.createMessage(errorMessage("No active slowmodes!"));
+    }
+  },
+});
diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeSetChannelCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeSetChannelCmd.ts
new file mode 100644
index 00000000..ab59ce26
--- /dev/null
+++ b/backend/src/plugins/Slowmode/commands/SlowmodeSetChannelCmd.ts
@@ -0,0 +1,93 @@
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+import { slowmodeCmd } from "../types";
+import { TextChannel } from "eris";
+import humanizeDuration from "humanize-duration";
+import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
+import { convertDelayStringToMS, HOURS, DAYS } from "src/utils";
+import { disableBotSlowmodeForChannel } from "../util/disableBotSlowmodeForChannel";
+import { actualDisableSlowmodeCmd } from "../util/actualDisableSlowmodeCmd";
+
+const NATIVE_SLOWMODE_LIMIT = 6 * HOURS; // 6 hours
+const MAX_SLOWMODE = DAYS * 365 * 100; // 100 years
+
+export const SlowmodeSetChannelCmd = slowmodeCmd({
+  trigger: "slowmode",
+  permission: "can_manage",
+  source: "guild",
+
+  // prettier-ignore
+  signature: [
+    {
+      time: ct.string(),
+    },
+    {
+      channel: ct.textChannel(),
+      time: ct.string(),
+    }
+  ],
+
+  async run({ message: msg, args, pluginData }) {
+    const channel = args.channel || msg.channel;
+
+    if (channel == null || !(channel instanceof TextChannel)) {
+      sendErrorMessage(pluginData, msg.channel, "Channel must be a text channel");
+      return;
+    }
+
+    const seconds = Math.ceil(convertDelayStringToMS(args.time, "s") / 1000);
+    const useNativeSlowmode =
+      pluginData.config.getForChannel(channel).use_native_slowmode && seconds <= NATIVE_SLOWMODE_LIMIT;
+
+    if (seconds === 0) {
+      // Workaround until we can call SlowmodeDisableCmd from here
+      return actualDisableSlowmodeCmd(msg, { channel }, pluginData);
+    }
+
+    if (seconds > MAX_SLOWMODE) {
+      sendErrorMessage(
+        pluginData,
+        msg.channel,
+        `Sorry, slowmodes can be at most 100 years long. Maybe 99 would be enough?`,
+      );
+      return;
+    }
+
+    if (useNativeSlowmode) {
+      // Native slowmode
+
+      // If there is an existing bot-maintained slowmode, disable that first
+      const existingBotSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id);
+      if (existingBotSlowmode) {
+        await disableBotSlowmodeForChannel(pluginData, channel);
+      }
+
+      // Set slowmode
+      try {
+        await channel.edit({
+          rateLimitPerUser: seconds,
+        });
+      } catch (e) {
+        return sendErrorMessage(pluginData, msg.channel, "Failed to set native slowmode (check permissions)");
+      }
+    } else {
+      // Bot-maintained slowmode
+
+      // If there is an existing native slowmode, disable that first
+      if (channel.rateLimitPerUser) {
+        await channel.edit({
+          rateLimitPerUser: 0,
+        });
+      }
+
+      await pluginData.state.slowmodes.setChannelSlowmode(channel.id, seconds);
+    }
+
+    const humanizedSlowmodeTime = humanizeDuration(seconds * 1000);
+    const slowmodeType = useNativeSlowmode ? "native slowmode" : "bot-maintained slowmode";
+    sendSuccessMessage(
+      pluginData,
+      msg.channel,
+      `Set ${humanizedSlowmodeTime} slowmode for <#${channel.id}> (${slowmodeType})`,
+    );
+  },
+});
diff --git a/backend/src/plugins/Slowmode/types.ts b/backend/src/plugins/Slowmode/types.ts
new file mode 100644
index 00000000..0f956644
--- /dev/null
+++ b/backend/src/plugins/Slowmode/types.ts
@@ -0,0 +1,28 @@
+import * as t from "io-ts";
+import { BasePluginType, eventListener, command } from "knub";
+import { GuildSlowmodes } from "src/data/GuildSlowmodes";
+import { GuildSavedMessages } from "src/data/GuildSavedMessages";
+import { GuildLogs } from "src/data/GuildLogs";
+
+export const ConfigSchema = t.type({
+  use_native_slowmode: t.boolean,
+
+  can_manage: t.boolean,
+  is_affected: t.boolean,
+});
+export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+
+export interface SlowmodePluginType extends BasePluginType {
+  config: TConfigSchema;
+  state: {
+    slowmodes: GuildSlowmodes;
+    savedMessages: GuildSavedMessages;
+    logs: GuildLogs;
+    clearInterval: NodeJS.Timeout;
+
+    onMessageCreateFn;
+  };
+}
+
+export const slowmodeCmd = command<SlowmodePluginType>();
+export const slowmodeEvt = eventListener<SlowmodePluginType>();
diff --git a/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts b/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts
new file mode 100644
index 00000000..948c474a
--- /dev/null
+++ b/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts
@@ -0,0 +1,39 @@
+import { Message } from "eris";
+import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
+import { disableBotSlowmodeForChannel } from "./disableBotSlowmodeForChannel";
+import { noop } from "src/utils";
+
+export async function actualDisableSlowmodeCmd(msg: Message, args, pluginData) {
+  const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(args.channel.id);
+  const hasNativeSlowmode = args.channel.rateLimitPerUser;
+
+  if (!botSlowmode && hasNativeSlowmode === 0) {
+    sendErrorMessage(pluginData, msg.channel, "Channel is not on slowmode!");
+    return;
+  }
+
+  const initMsg = await msg.channel.createMessage("Disabling slowmode...");
+
+  // Disable bot-maintained slowmode
+  let failedUsers = [];
+  if (botSlowmode) {
+    const result = await disableBotSlowmodeForChannel(pluginData, args.channel);
+    failedUsers = result.failedUsers;
+  }
+
+  // Disable native slowmode
+  if (hasNativeSlowmode) {
+    await args.channel.edit({ rateLimitPerUser: 0 });
+  }
+
+  if (failedUsers.length) {
+    sendSuccessMessage(
+      pluginData,
+      msg.channel,
+      `Slowmode disabled! Failed to clear slowmode from the following users:\n\n<@!${failedUsers.join(">\n<@!")}>`,
+    );
+  } else {
+    sendSuccessMessage(pluginData, msg.channel, "Slowmode disabled!");
+    initMsg.delete().catch(noop);
+  }
+}
diff --git a/backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts b/backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts
new file mode 100644
index 00000000..0b22a738
--- /dev/null
+++ b/backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts
@@ -0,0 +1,43 @@
+import { SlowmodePluginType } from "../types";
+import { PluginData } from "knub";
+import { GuildChannel, TextChannel, Constants } from "eris";
+import { UnknownUser, isDiscordRESTError, stripObjectToScalars } from "src/utils";
+import { LogType } from "src/data/LogType";
+import { logger } from "src/logger";
+
+export async function applyBotSlowmodeToUserId(
+  pluginData: PluginData<SlowmodePluginType>,
+  channel: GuildChannel & TextChannel,
+  userId: string,
+) {
+  // Deny sendMessage permission from the user. If there are existing permission overwrites, take those into account.
+  const existingOverride = channel.permissionOverwrites.get(userId);
+  const newDeniedPermissions = (existingOverride ? existingOverride.deny : 0) | Constants.Permissions.sendMessages;
+  const newAllowedPermissions = (existingOverride ? existingOverride.allow : 0) & ~Constants.Permissions.sendMessages;
+
+  try {
+    await channel.editPermission(userId, newAllowedPermissions, newDeniedPermissions, "member");
+  } catch (e) {
+    const user = pluginData.client.users.get(userId) || new UnknownUser({ id: userId });
+
+    if (isDiscordRESTError(e) && e.code === 50013) {
+      logger.warn(
+        `Missing permissions to apply bot slowmode to user ${userId} on channel ${channel.name} (${channel.id}) on server ${pluginData.guild.name} (${pluginData.guild.id})`,
+      );
+      pluginData.state.logs.log(LogType.BOT_ALERT, {
+        body: `Missing permissions to apply bot slowmode to {userMention(user)} in {channelMention(channel)}`,
+        user: stripObjectToScalars(user),
+        channel: stripObjectToScalars(channel),
+      });
+    } else {
+      pluginData.state.logs.log(LogType.BOT_ALERT, {
+        body: `Failed to apply bot slowmode to {userMention(user)} in {channelMention(channel)}`,
+        user: stripObjectToScalars(user),
+        channel: stripObjectToScalars(channel),
+      });
+      throw e;
+    }
+  }
+
+  await pluginData.state.slowmodes.addSlowmodeUser(channel.id, userId);
+}
diff --git a/backend/src/plugins/Slowmode/util/clearBotSlowmodeFromUserId.ts b/backend/src/plugins/Slowmode/util/clearBotSlowmodeFromUserId.ts
new file mode 100644
index 00000000..dcde2322
--- /dev/null
+++ b/backend/src/plugins/Slowmode/util/clearBotSlowmodeFromUserId.ts
@@ -0,0 +1,24 @@
+import { PluginData } from "knub";
+import { SlowmodePluginType } from "../types";
+import { GuildChannel, TextChannel } from "eris";
+
+export async function clearBotSlowmodeFromUserId(
+  pluginData: PluginData<SlowmodePluginType>,
+  channel: GuildChannel & TextChannel,
+  userId: string,
+  force = false,
+) {
+  try {
+    // Remove permission overrides from the channel for this user
+    // Previously we diffed the overrides so we could clear the "send messages" override without touching other
+    // overrides. Unfortunately, it seems that was a bit buggy - we didn't always receive the event for the changed
+    // overrides and then we also couldn't diff against them. For consistency's sake, we just delete the override now.
+    await channel.deletePermission(userId);
+  } catch (e) {
+    if (!force) {
+      throw e;
+    }
+  }
+
+  await pluginData.state.slowmodes.clearSlowmodeUser(channel.id, userId);
+}
diff --git a/backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts b/backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts
new file mode 100644
index 00000000..1f0889a9
--- /dev/null
+++ b/backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts
@@ -0,0 +1,31 @@
+import { PluginData } from "knub";
+import { SlowmodePluginType } from "../types";
+import { LogType } from "src/data/LogType";
+import { logger } from "src/logger";
+import { GuildChannel, TextChannel } from "eris";
+import { UnknownUser, stripObjectToScalars } from "src/utils";
+import { clearBotSlowmodeFromUserId } from "./clearBotSlowmodeFromUserId";
+
+export async function clearExpiredSlowmodes(pluginData: PluginData<SlowmodePluginType>) {
+  const expiredSlowmodeUsers = await pluginData.state.slowmodes.getExpiredSlowmodeUsers();
+  for (const user of expiredSlowmodeUsers) {
+    const channel = pluginData.guild.channels.get(user.channel_id);
+    if (!channel) {
+      await pluginData.state.slowmodes.clearSlowmodeUser(user.channel_id, user.user_id);
+      continue;
+    }
+
+    try {
+      await clearBotSlowmodeFromUserId(pluginData, channel as GuildChannel & TextChannel, user.user_id);
+    } catch (e) {
+      logger.error(e);
+
+      const realUser = pluginData.client.users.get(user.user_id) || new UnknownUser({ id: user.user_id });
+      pluginData.state.logs.log(LogType.BOT_ALERT, {
+        body: `Failed to clear slowmode permissions from {userMention(user)} in {channelMention(channel)}`,
+        user: stripObjectToScalars(realUser),
+        channel: stripObjectToScalars(channel),
+      });
+    }
+  }
+}
diff --git a/backend/src/plugins/Slowmode/util/disableBotSlowmodeForChannel.ts b/backend/src/plugins/Slowmode/util/disableBotSlowmodeForChannel.ts
new file mode 100644
index 00000000..f505a9c1
--- /dev/null
+++ b/backend/src/plugins/Slowmode/util/disableBotSlowmodeForChannel.ts
@@ -0,0 +1,28 @@
+import { GuildChannel, TextChannel } from "eris";
+import { PluginData } from "knub";
+import { SlowmodePluginType } from "../types";
+import { clearBotSlowmodeFromUserId } from "./clearBotSlowmodeFromUserId";
+
+export async function disableBotSlowmodeForChannel(
+  pluginData: PluginData<SlowmodePluginType>,
+  channel: GuildChannel & TextChannel,
+) {
+  // Disable channel slowmode
+  await pluginData.state.slowmodes.deleteChannelSlowmode(channel.id);
+
+  // Remove currently applied slowmodes
+  const users = await pluginData.state.slowmodes.getChannelSlowmodeUsers(channel.id);
+  const failedUsers = [];
+
+  for (const slowmodeUser of users) {
+    try {
+      await clearBotSlowmodeFromUserId(pluginData, channel, slowmodeUser.user_id);
+    } catch (e) {
+      // Removing the slowmode failed. Record this so the permissions can be changed manually, and remove the database entry.
+      failedUsers.push(slowmodeUser.user_id);
+      await pluginData.state.slowmodes.clearSlowmodeUser(channel.id, slowmodeUser.user_id);
+    }
+  }
+
+  return { failedUsers };
+}
diff --git a/backend/src/plugins/Slowmode/util/onMessageCreate.ts b/backend/src/plugins/Slowmode/util/onMessageCreate.ts
new file mode 100644
index 00000000..13bebcd3
--- /dev/null
+++ b/backend/src/plugins/Slowmode/util/onMessageCreate.ts
@@ -0,0 +1,42 @@
+import { SavedMessage } from "src/data/entities/SavedMessage";
+import { GuildChannel, TextChannel } from "eris";
+import { PluginData } from "knub";
+import { SlowmodePluginType } from "../types";
+import { resolveMember } from "src/utils";
+import { applyBotSlowmodeToUserId } from "./applyBotSlowmodeToUserId";
+import { hasPermission } from "src/pluginUtils";
+
+export async function onMessageCreate(pluginData: PluginData<SlowmodePluginType>, msg: SavedMessage) {
+  if (msg.is_bot) return;
+
+  const channel = pluginData.guild.channels.get(msg.channel_id) as GuildChannel & TextChannel;
+  if (!channel) return;
+
+  // Don't apply slowmode if the lock was interrupted earlier (e.g. the message was caught by word filters)
+  const thisMsgLock = await pluginData.locks.acquire(`message-${msg.id}`);
+  if (thisMsgLock.interrupted) return;
+
+  // Check if this channel even *has* a bot-maintained slowmode
+  const channelSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id);
+  if (!channelSlowmode) return thisMsgLock.unlock();
+
+  // Make sure this user is affected by the slowmode
+  const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id);
+  const isAffected = hasPermission(pluginData, "is_affected", { channelId: channel.id, userId: msg.user_id, member });
+  if (!isAffected) return thisMsgLock.unlock();
+
+  // Delete any extra messages sent after a slowmode was already applied
+  const userHasSlowmode = await pluginData.state.slowmodes.userHasSlowmode(channel.id, msg.user_id);
+  if (userHasSlowmode) {
+    const message = await channel.getMessage(msg.id);
+    if (message) {
+      message.delete();
+      return thisMsgLock.interrupt();
+    }
+
+    return thisMsgLock.unlock();
+  }
+
+  await applyBotSlowmodeToUserId(pluginData, channel, msg.user_id);
+  thisMsgLock.unlock();
+}
diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts
index 32825725..f5b1acb3 100644
--- a/backend/src/plugins/availablePlugins.ts
+++ b/backend/src/plugins/availablePlugins.ts
@@ -13,6 +13,7 @@ import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigRelo
 import { CasesPlugin } from "./Cases/CasesPlugin";
 import { MutesPlugin } from "./Mutes/MutesPlugin";
 import { TagsPlugin } from "./Tags/TagsPlugin";
+import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin";
 
 // prettier-ignore
 export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
@@ -23,6 +24,7 @@ export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
   MessageSaverPlugin,
   NameHistoryPlugin,
   RemindersPlugin,
+  SlowmodePlugin,
   TagsPlugin,
   UsernameSaverPlugin,
   UtilityPlugin,