From 5c070643a3647116246a18cbc5a25859d2a4e6bc Mon Sep 17 00:00:00 2001
From: Dark <7890309+DarkView@users.noreply.github.com>
Date: Thu, 23 Jul 2020 21:26:22 +0200
Subject: [PATCH] Migrate Post to new Plugin structure

---
 backend/src/plugins/Post/PostPlugin.ts        |  58 ++++++
 backend/src/plugins/Post/commands/EditCmd.ts  |  30 +++
 .../src/plugins/Post/commands/EditEmbedCmd.ts |  63 ++++++
 backend/src/plugins/Post/commands/PostCmd.ts  |  23 +++
 .../src/plugins/Post/commands/PostEmbedCmd.ts |  76 +++++++
 .../Post/commands/SchedluedPostsDeleteCmd.ts  |  25 +++
 .../Post/commands/ScheduledPostsListCmd.ts    |  57 ++++++
 .../Post/commands/ScheduledPostsShowCmd.ts    |  26 +++
 backend/src/plugins/Post/types.ts             |  23 +++
 .../src/plugins/Post/util/actualPostCmd.ts    | 185 ++++++++++++++++++
 .../src/plugins/Post/util/formatContent.ts    |   3 +
 .../plugins/Post/util/parseScheduleTime.ts    |  32 +++
 backend/src/plugins/Post/util/postMessage.ts  |  67 +++++++
 .../plugins/Post/util/scheduledPostLoop.ts    |  82 ++++++++
 backend/src/plugins/availablePlugins.ts       |   2 +
 15 files changed, 752 insertions(+)
 create mode 100644 backend/src/plugins/Post/PostPlugin.ts
 create mode 100644 backend/src/plugins/Post/commands/EditCmd.ts
 create mode 100644 backend/src/plugins/Post/commands/EditEmbedCmd.ts
 create mode 100644 backend/src/plugins/Post/commands/PostCmd.ts
 create mode 100644 backend/src/plugins/Post/commands/PostEmbedCmd.ts
 create mode 100644 backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts
 create mode 100644 backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts
 create mode 100644 backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts
 create mode 100644 backend/src/plugins/Post/types.ts
 create mode 100644 backend/src/plugins/Post/util/actualPostCmd.ts
 create mode 100644 backend/src/plugins/Post/util/formatContent.ts
 create mode 100644 backend/src/plugins/Post/util/parseScheduleTime.ts
 create mode 100644 backend/src/plugins/Post/util/postMessage.ts
 create mode 100644 backend/src/plugins/Post/util/scheduledPostLoop.ts

diff --git a/backend/src/plugins/Post/PostPlugin.ts b/backend/src/plugins/Post/PostPlugin.ts
new file mode 100644
index 00000000..5308d0ba
--- /dev/null
+++ b/backend/src/plugins/Post/PostPlugin.ts
@@ -0,0 +1,58 @@
+import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
+import { PluginOptions } from "knub";
+import { ConfigSchema, PostPluginType } from "./types";
+import { GuildSavedMessages } from "src/data/GuildSavedMessages";
+import { GuildScheduledPosts } from "src/data/GuildScheduledPosts";
+import { GuildLogs } from "src/data/GuildLogs";
+import { PostCmd } from "./commands/PostCmd";
+import { PostEmbedCmd } from "./commands/PostEmbedCmd";
+import { EditCmd } from "./commands/EditCmd";
+import { EditEmbedCmd } from "./commands/EditEmbedCmd";
+import { ScheduledPostsShowCmd } from "./commands/ScheduledPostsShowCmd";
+import { ScheduledPostsListCmd } from "./commands/ScheduledPostsListCmd";
+import { ScheduledPostsDeleteCmd } from "./commands/SchedluedPostsDeleteCmd";
+import { scheduledPostLoop } from "./util/scheduledPostLoop";
+
+const defaultOptions: PluginOptions<PostPluginType> = {
+  config: {
+    can_post: false,
+  },
+  overrides: [
+    {
+      level: ">=100",
+      config: {
+        can_post: true,
+      },
+    },
+  ],
+};
+
+export const PostPlugin = zeppelinPlugin<PostPluginType>()("post", {
+  configSchema: ConfigSchema,
+  defaultOptions,
+
+  // prettier-ignore
+  commands: [
+      PostCmd,
+      PostEmbedCmd,
+      EditCmd,
+      EditEmbedCmd,
+      ScheduledPostsShowCmd,
+      ScheduledPostsListCmd,
+      ScheduledPostsDeleteCmd,
+  ],
+
+  onLoad(pluginData) {
+    const { state, guild } = pluginData;
+
+    state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);
+    state.scheduledPosts = GuildScheduledPosts.getGuildInstance(guild.id);
+    state.logs = new GuildLogs(guild.id);
+
+    scheduledPostLoop(pluginData);
+  },
+
+  onUnload(pluginData) {
+    clearTimeout(pluginData.state.scheduledPostLoopTimeout);
+  },
+});
diff --git a/backend/src/plugins/Post/commands/EditCmd.ts b/backend/src/plugins/Post/commands/EditCmd.ts
new file mode 100644
index 00000000..21594e13
--- /dev/null
+++ b/backend/src/plugins/Post/commands/EditCmd.ts
@@ -0,0 +1,30 @@
+import { postCmd } from "../types";
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
+import { formatContent } from "../util/formatContent";
+
+export const EditCmd = postCmd({
+  trigger: "edit",
+  permission: "can_post",
+
+  signature: {
+    messageId: ct.string(),
+    content: ct.string({ catchAll: true }),
+  },
+
+  async run({ message: msg, args, pluginData }) {
+    const savedMessage = await pluginData.state.savedMessages.find(args.messageId);
+    if (!savedMessage) {
+      sendErrorMessage(pluginData, msg.channel, "Unknown message");
+      return;
+    }
+
+    if (savedMessage.user_id !== pluginData.client.user.id) {
+      sendErrorMessage(pluginData, msg.channel, "Message wasn't posted by me");
+      return;
+    }
+
+    await pluginData.client.editMessage(savedMessage.channel_id, savedMessage.id, formatContent(args.content));
+    sendSuccessMessage(pluginData, msg.channel, "Message edited");
+  },
+});
diff --git a/backend/src/plugins/Post/commands/EditEmbedCmd.ts b/backend/src/plugins/Post/commands/EditEmbedCmd.ts
new file mode 100644
index 00000000..4ccb4d98
--- /dev/null
+++ b/backend/src/plugins/Post/commands/EditEmbedCmd.ts
@@ -0,0 +1,63 @@
+import { postCmd } from "../types";
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
+import { Embed } from "eris";
+import { trimLines } from "src/utils";
+import { formatContent } from "../util/formatContent";
+
+const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/;
+
+export const EditEmbedCmd = postCmd({
+  trigger: "edit_embed",
+  permission: "can_post",
+
+  signature: {
+    messageId: ct.string(),
+    maincontent: ct.string({ catchAll: true }),
+
+    title: ct.string({ option: true }),
+    content: ct.string({ option: true }),
+    color: ct.string({ option: true }),
+  },
+
+  async run({ message: msg, args, pluginData }) {
+    const savedMessage = await pluginData.state.savedMessages.find(args.messageId);
+    if (!savedMessage) {
+      sendErrorMessage(pluginData, msg.channel, "Unknown message");
+      return;
+    }
+
+    const content = args.content || args.maincontent;
+
+    let color = null;
+    if (args.color) {
+      const colorMatch = args.color.match(COLOR_MATCH_REGEX);
+      if (!colorMatch) {
+        sendErrorMessage(pluginData, msg.channel, "Invalid color specified, use hex colors");
+        return;
+      }
+
+      color = parseInt(colorMatch[1], 16);
+    }
+
+    const embed: Embed = savedMessage.data.embeds[0] as Embed;
+    embed.type = "rich";
+    if (args.title) embed.title = args.title;
+    if (content) embed.description = formatContent(content);
+    if (color) embed.color = color;
+
+    await pluginData.client.editMessage(savedMessage.channel_id, savedMessage.id, { embed });
+    await sendSuccessMessage(pluginData, msg.channel, "Embed edited");
+
+    if (args.content) {
+      const prefix = pluginData.guildConfig.prefix || "!";
+      msg.channel.createMessage(
+        trimLines(`
+        <@!${msg.author.id}> You can now specify an embed's content directly at the end of the command:
+        \`${prefix}edit_embed -title "Some title" content goes here\`
+        The \`-content\` option will soon be removed in favor of this.
+      `),
+      );
+    }
+  },
+});
diff --git a/backend/src/plugins/Post/commands/PostCmd.ts b/backend/src/plugins/Post/commands/PostCmd.ts
new file mode 100644
index 00000000..25ae5a6b
--- /dev/null
+++ b/backend/src/plugins/Post/commands/PostCmd.ts
@@ -0,0 +1,23 @@
+import { postCmd } from "../types";
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+import { actualPostCmd } from "../util/actualPostCmd";
+
+export const PostCmd = postCmd({
+  trigger: "post",
+  permission: "can_post",
+
+  signature: {
+    channel: ct.textChannel(),
+    content: ct.string({ catchAll: true }),
+
+    "enable-mentions": ct.bool({ option: true, isSwitch: true }),
+    schedule: ct.string({ option: true }),
+    repeat: ct.delay({ option: true }),
+    "repeat-until": ct.string({ option: true }),
+    "repeat-times": ct.number({ option: true }),
+  },
+
+  async run({ message: msg, args, pluginData }) {
+    actualPostCmd(pluginData, msg, args.channel, { content: args.content }, args);
+  },
+});
diff --git a/backend/src/plugins/Post/commands/PostEmbedCmd.ts b/backend/src/plugins/Post/commands/PostEmbedCmd.ts
new file mode 100644
index 00000000..299aef7c
--- /dev/null
+++ b/backend/src/plugins/Post/commands/PostEmbedCmd.ts
@@ -0,0 +1,76 @@
+import { postCmd } from "../types";
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+import { actualPostCmd } from "../util/actualPostCmd";
+import { sendErrorMessage } from "src/pluginUtils";
+import { Embed } from "eris";
+import { isValidEmbed } from "src/utils";
+import { formatContent } from "../util/formatContent";
+
+const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/;
+
+export const PostEmbedCmd = postCmd({
+  trigger: "post_embed",
+  permission: "can_post",
+
+  signature: {
+    channel: ct.textChannel(),
+    maincontent: ct.string({ catchAll: true }),
+
+    title: ct.string({ option: true }),
+    content: ct.string({ option: true }),
+    color: ct.string({ option: true }),
+    raw: ct.bool({ option: true, isSwitch: true, shortcut: "r" }),
+
+    schedule: ct.string({ option: true }),
+    repeat: ct.delay({ option: true }),
+    "repeat-until": ct.string({ option: true }),
+    "repeat-times": ct.number({ option: true }),
+  },
+
+  async run({ message: msg, args, pluginData }) {
+    const content = args.content || args.maincontent;
+
+    if (!args.title && !content) {
+      sendErrorMessage(pluginData, msg.channel, "Title or content required");
+      return;
+    }
+
+    let color = null;
+    if (args.color) {
+      const colorMatch = args.color.toLowerCase().match(COLOR_MATCH_REGEX);
+      if (!colorMatch) {
+        sendErrorMessage(pluginData, msg.channel, "Invalid color specified, use hex colors");
+        return;
+      }
+
+      color = parseInt(colorMatch[1], 16);
+    }
+
+    let embed: Embed = { type: "rich" };
+    if (args.title) embed.title = args.title;
+    if (color) embed.color = color;
+
+    if (content) {
+      if (args.raw) {
+        let parsed;
+        try {
+          parsed = JSON.parse(content);
+        } catch (e) {
+          sendErrorMessage(pluginData, msg.channel, "Syntax error in embed JSON");
+          return;
+        }
+
+        if (!isValidEmbed(parsed)) {
+          sendErrorMessage(pluginData, msg.channel, "Embed is not valid");
+          return;
+        }
+
+        embed = Object.assign({}, embed, parsed);
+      } else {
+        embed.description = formatContent(content);
+      }
+    }
+
+    actualPostCmd(pluginData, msg, args.channel, { embed }, args);
+  },
+});
diff --git a/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts b/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts
new file mode 100644
index 00000000..f78e7006
--- /dev/null
+++ b/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts
@@ -0,0 +1,25 @@
+import { postCmd } from "../types";
+import { sorter } from "src/utils";
+import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+
+export const ScheduledPostsDeleteCmd = postCmd({
+  trigger: ["scheduled_posts delete", "scheduled_posts d"],
+  permission: "can_post",
+
+  signature: {
+    num: ct.number(),
+  },
+
+  async run({ message: msg, args, pluginData }) {
+    const scheduledPosts = await pluginData.state.scheduledPosts.all();
+    scheduledPosts.sort(sorter("post_at"));
+    const post = scheduledPosts[args.num - 1];
+    if (!post) {
+      return sendErrorMessage(pluginData, msg.channel, "Scheduled post not found");
+    }
+
+    await pluginData.state.scheduledPosts.delete(post.id);
+    sendSuccessMessage(pluginData, msg.channel, "Scheduled post deleted!");
+  },
+});
diff --git a/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts
new file mode 100644
index 00000000..85d3f950
--- /dev/null
+++ b/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts
@@ -0,0 +1,57 @@
+import { postCmd } from "../types";
+import { trimLines, sorter, disableCodeBlocks, deactivateMentions, createChunkedMessage } from "src/utils";
+import humanizeDuration from "humanize-duration";
+
+const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50;
+
+export const ScheduledPostsListCmd = postCmd({
+  trigger: ["scheduled_posts", "scheduled_posts list"],
+  permission: "can_post",
+
+  async run({ message: msg, pluginData }) {
+    const scheduledPosts = await pluginData.state.scheduledPosts.all();
+    if (scheduledPosts.length === 0) {
+      msg.channel.createMessage("No scheduled posts");
+      return;
+    }
+
+    scheduledPosts.sort(sorter("post_at"));
+
+    let i = 1;
+    const postLines = scheduledPosts.map(p => {
+      let previewText =
+        p.content.content || (p.content.embed && (p.content.embed.description || p.content.embed.title)) || "";
+
+      const isTruncated = previewText.length > SCHEDULED_POST_PREVIEW_TEXT_LENGTH;
+
+      previewText = disableCodeBlocks(deactivateMentions(previewText))
+        .replace(/\s+/g, " ")
+        .slice(0, SCHEDULED_POST_PREVIEW_TEXT_LENGTH);
+
+      const parts = [`\`#${i++}\` \`[${p.post_at}]\` ${previewText}${isTruncated ? "..." : ""}`];
+      if (p.attachments.length) parts.push("*(with attachment)*");
+      if (p.content.embed) parts.push("*(embed)*");
+      if (p.repeat_until) {
+        parts.push(`*(repeated every ${humanizeDuration(p.repeat_interval)} until ${p.repeat_until})*`);
+      }
+      if (p.repeat_times) {
+        parts.push(
+          `*(repeated every ${humanizeDuration(p.repeat_interval)}, ${p.repeat_times} more ${
+            p.repeat_times === 1 ? "time" : "times"
+          })*`,
+        );
+      }
+      parts.push(`*(${p.author_name})*`);
+
+      return parts.join(" ");
+    });
+
+    const finalMessage = trimLines(`
+      ${postLines.join("\n")}
+      
+      Use \`scheduled_posts <num>\` to view a scheduled post in full
+      Use \`scheduled_posts delete <num>\` to delete a scheduled post
+    `);
+    createChunkedMessage(msg.channel, finalMessage);
+  },
+});
diff --git a/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts
new file mode 100644
index 00000000..cc0e3a42
--- /dev/null
+++ b/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts
@@ -0,0 +1,26 @@
+import { postCmd } from "../types";
+import { sorter } from "src/utils";
+import { sendErrorMessage } from "src/pluginUtils";
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+import { postMessage } from "../util/postMessage";
+import { TextChannel } from "eris";
+
+export const ScheduledPostsShowCmd = postCmd({
+  trigger: ["scheduled_posts", "scheduled_posts show"],
+  permission: "can_post",
+
+  signature: {
+    num: ct.number(),
+  },
+
+  async run({ message: msg, args, pluginData }) {
+    const scheduledPosts = await pluginData.state.scheduledPosts.all();
+    scheduledPosts.sort(sorter("post_at"));
+    const post = scheduledPosts[args.num - 1];
+    if (!post) {
+      return sendErrorMessage(pluginData, msg.channel, "Scheduled post not found");
+    }
+
+    postMessage(pluginData, msg.channel as TextChannel, post.content, post.attachments, post.enable_mentions);
+  },
+});
diff --git a/backend/src/plugins/Post/types.ts b/backend/src/plugins/Post/types.ts
new file mode 100644
index 00000000..f1d697bf
--- /dev/null
+++ b/backend/src/plugins/Post/types.ts
@@ -0,0 +1,23 @@
+import * as t from "io-ts";
+import { BasePluginType, command } from "knub";
+import { GuildSavedMessages } from "src/data/GuildSavedMessages";
+import { GuildScheduledPosts } from "src/data/GuildScheduledPosts";
+import { GuildLogs } from "src/data/GuildLogs";
+
+export const ConfigSchema = t.type({
+  can_post: t.boolean,
+});
+export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+
+export interface PostPluginType extends BasePluginType {
+  config: TConfigSchema;
+  state: {
+    savedMessages: GuildSavedMessages;
+    scheduledPosts: GuildScheduledPosts;
+    logs: GuildLogs;
+
+    scheduledPostLoopTimeout: NodeJS.Timeout;
+  };
+}
+
+export const postCmd = command<PostPluginType>();
diff --git a/backend/src/plugins/Post/util/actualPostCmd.ts b/backend/src/plugins/Post/util/actualPostCmd.ts
new file mode 100644
index 00000000..556d05a0
--- /dev/null
+++ b/backend/src/plugins/Post/util/actualPostCmd.ts
@@ -0,0 +1,185 @@
+import { Message, Channel, TextChannel } from "eris";
+import { StrictMessageContent, errorMessage, DBDateFormat, stripObjectToScalars, MINUTES } from "src/utils";
+import moment from "moment-timezone";
+import { LogType } from "src/data/LogType";
+import humanizeDuration from "humanize-duration";
+import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
+import { PluginData } from "knub";
+import { PostPluginType } from "../types";
+import { parseScheduleTime } from "./parseScheduleTime";
+import { postMessage } from "./postMessage";
+
+const MIN_REPEAT_TIME = 5 * MINUTES;
+const MAX_REPEAT_TIME = Math.pow(2, 32);
+const MAX_REPEAT_UNTIL = moment().add(100, "years");
+
+export async function actualPostCmd(
+  pluginData: PluginData<PostPluginType>,
+  msg: Message,
+  targetChannel: Channel,
+  content: StrictMessageContent,
+  opts?: {
+    "enable-mentions"?: boolean;
+    schedule?: string;
+    repeat?: number;
+    "repeat-until"?: string;
+    "repeat-times"?: number;
+  },
+) {
+  if (!(targetChannel instanceof TextChannel)) {
+    msg.channel.createMessage(errorMessage("Channel is not a text channel"));
+    return;
+  }
+
+  if (content == null && msg.attachments.length === 0) {
+    msg.channel.createMessage(errorMessage("Message content or attachment required"));
+    return;
+  }
+
+  if (opts.repeat) {
+    if (opts.repeat < MIN_REPEAT_TIME) {
+      return sendErrorMessage(
+        pluginData,
+        msg.channel,
+        `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`,
+      );
+    }
+    if (opts.repeat > MAX_REPEAT_TIME) {
+      return sendErrorMessage(pluginData, msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`);
+    }
+  }
+
+  // If this is a scheduled or repeated post, figure out the next post date
+  let postAt;
+  if (opts.schedule) {
+    // Schedule the post to be posted later
+    postAt = parseScheduleTime(opts.schedule);
+    if (!postAt) {
+      return sendErrorMessage(pluginData, msg.channel, "Invalid schedule time");
+    }
+  } else if (opts.repeat) {
+    postAt = moment().add(opts.repeat, "ms");
+  }
+
+  // For repeated posts, make sure repeat-until or repeat-times is specified
+  let repeatUntil: moment.Moment = null;
+  let repeatTimes: number = null;
+  let repeatDetailsStr: string = null;
+
+  if (opts["repeat-until"]) {
+    repeatUntil = parseScheduleTime(opts["repeat-until"]);
+
+    // Invalid time
+    if (!repeatUntil) {
+      return sendErrorMessage(pluginData, msg.channel, "Invalid time specified for -repeat-until");
+    }
+    if (repeatUntil.isBefore(moment())) {
+      return sendErrorMessage(pluginData, msg.channel, "You can't set -repeat-until in the past");
+    }
+    if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) {
+      return sendErrorMessage(
+        pluginData,
+        msg.channel,
+        "Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?",
+      );
+    }
+  } else if (opts["repeat-times"]) {
+    repeatTimes = opts["repeat-times"];
+    if (repeatTimes <= 0) {
+      return sendErrorMessage(pluginData, msg.channel, "-repeat-times must be 1 or more");
+    }
+  }
+
+  if (repeatUntil && repeatTimes) {
+    return sendErrorMessage(pluginData, msg.channel, "You can only use one of -repeat-until or -repeat-times at once");
+  }
+
+  if (opts.repeat && !repeatUntil && !repeatTimes) {
+    return sendErrorMessage(
+      pluginData,
+      msg.channel,
+      "You must specify -repeat-until or -repeat-times for repeated messages",
+    );
+  }
+
+  if (opts.repeat) {
+    repeatDetailsStr = repeatUntil
+      ? `every ${humanizeDuration(opts.repeat)} until ${repeatUntil.format(DBDateFormat)}`
+      : `every ${humanizeDuration(opts.repeat)}, ${repeatTimes} times in total`;
+  }
+
+  // Save schedule/repeat information in DB
+  if (postAt) {
+    if (postAt < moment()) {
+      return sendErrorMessage(pluginData, msg.channel, "Post can't be scheduled to be posted in the past");
+    }
+
+    await pluginData.state.scheduledPosts.create({
+      author_id: msg.author.id,
+      author_name: `${msg.author.username}#${msg.author.discriminator}`,
+      channel_id: targetChannel.id,
+      content,
+      attachments: msg.attachments,
+      post_at: postAt.format(DBDateFormat),
+      enable_mentions: opts["enable-mentions"],
+      repeat_interval: opts.repeat,
+      repeat_until: repeatUntil ? repeatUntil.format(DBDateFormat) : null,
+      repeat_times: repeatTimes ?? null,
+    });
+
+    if (opts.repeat) {
+      pluginData.state.logs.log(LogType.SCHEDULED_REPEATED_MESSAGE, {
+        author: stripObjectToScalars(msg.author),
+        channel: stripObjectToScalars(targetChannel),
+        date: postAt.format("YYYY-MM-DD"),
+        time: postAt.format("HH:mm:ss"),
+        repeatInterval: humanizeDuration(opts.repeat),
+        repeatDetails: repeatDetailsStr,
+      });
+    } else {
+      pluginData.state.logs.log(LogType.SCHEDULED_MESSAGE, {
+        author: stripObjectToScalars(msg.author),
+        channel: stripObjectToScalars(targetChannel),
+        date: postAt.format("YYYY-MM-DD"),
+        time: postAt.format("HH:mm:ss"),
+      });
+    }
+  }
+
+  // When the message isn't scheduled for later, post it immediately
+  if (!opts.schedule) {
+    await postMessage(pluginData, targetChannel, content, msg.attachments, opts["enable-mentions"]);
+  }
+
+  if (opts.repeat) {
+    pluginData.state.logs.log(LogType.REPEATED_MESSAGE, {
+      author: stripObjectToScalars(msg.author),
+      channel: stripObjectToScalars(targetChannel),
+      date: postAt.format("YYYY-MM-DD"),
+      time: postAt.format("HH:mm:ss"),
+      repeatInterval: humanizeDuration(opts.repeat),
+      repeatDetails: repeatDetailsStr,
+    });
+  }
+
+  // Bot reply schenanigans
+  let successMessage = opts.schedule
+    ? `Message scheduled to be posted in <#${targetChannel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`
+    : `Message posted in <#${targetChannel.id}>`;
+
+  if (opts.repeat) {
+    successMessage += `. Message will be automatically reposted every ${humanizeDuration(opts.repeat)}`;
+
+    if (repeatUntil) {
+      successMessage += ` until ${repeatUntil.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`;
+    } else if (repeatTimes) {
+      successMessage += `, ${repeatTimes} times in total`;
+    }
+
+    successMessage += ".";
+  }
+
+  if (targetChannel.id !== msg.channel.id || opts.schedule || opts.repeat) {
+    sendSuccessMessage(pluginData, msg.channel, successMessage);
+  }
+}
diff --git a/backend/src/plugins/Post/util/formatContent.ts b/backend/src/plugins/Post/util/formatContent.ts
new file mode 100644
index 00000000..e8d6ba93
--- /dev/null
+++ b/backend/src/plugins/Post/util/formatContent.ts
@@ -0,0 +1,3 @@
+export function formatContent(str) {
+  return str.replace(/\\n/g, "\n");
+}
diff --git a/backend/src/plugins/Post/util/parseScheduleTime.ts b/backend/src/plugins/Post/util/parseScheduleTime.ts
new file mode 100644
index 00000000..c4d57231
--- /dev/null
+++ b/backend/src/plugins/Post/util/parseScheduleTime.ts
@@ -0,0 +1,32 @@
+import moment, { Moment } from "moment-timezone";
+import { convertDelayStringToMS } from "src/utils";
+
+export function parseScheduleTime(str): Moment {
+  const dt1 = moment(str, "YYYY-MM-DD HH:mm:ss");
+  if (dt1 && dt1.isValid()) return dt1;
+
+  const dt2 = moment(str, "YYYY-MM-DD HH:mm");
+  if (dt2 && dt2.isValid()) return dt2;
+
+  const date = moment(str, "YYYY-MM-DD");
+  if (date && date.isValid()) return date;
+
+  const t1 = moment(str, "HH:mm:ss");
+  if (t1 && t1.isValid()) {
+    if (t1.isBefore(moment())) t1.add(1, "day");
+    return t1;
+  }
+
+  const t2 = moment(str, "HH:mm");
+  if (t2 && t2.isValid()) {
+    if (t2.isBefore(moment())) t2.add(1, "day");
+    return t2;
+  }
+
+  const delayStringMS = convertDelayStringToMS(str, "m");
+  if (delayStringMS) {
+    return moment().add(delayStringMS, "ms");
+  }
+
+  return null;
+}
diff --git a/backend/src/plugins/Post/util/postMessage.ts b/backend/src/plugins/Post/util/postMessage.ts
new file mode 100644
index 00000000..e45bfb24
--- /dev/null
+++ b/backend/src/plugins/Post/util/postMessage.ts
@@ -0,0 +1,67 @@
+import { PluginData } from "knub";
+import { PostPluginType } from "../types";
+import { TextChannel, MessageContent, Attachment, Message, Role } from "eris";
+import { downloadFile, getRoleMentions } from "src/utils";
+import fs from "fs";
+import { formatContent } from "./formatContent";
+
+const fsp = fs.promises;
+
+export async function postMessage(
+  pluginData: PluginData<PostPluginType>,
+  channel: TextChannel,
+  content: MessageContent,
+  attachments: Attachment[] = [],
+  enableMentions: boolean = false,
+): Promise<Message> {
+  if (typeof content === "string") {
+    content = { content };
+  }
+
+  if (content && content.content) {
+    content.content = formatContent(content.content);
+  }
+
+  let downloadedAttachment;
+  let file;
+  if (attachments.length) {
+    downloadedAttachment = await downloadFile(attachments[0].url);
+    file = {
+      name: attachments[0].filename,
+      file: await fsp.readFile(downloadedAttachment.path),
+    };
+  }
+
+  const rolesMadeMentionable: Role[] = [];
+  if (enableMentions && content.content) {
+    const mentionedRoleIds = getRoleMentions(content.content);
+    if (mentionedRoleIds != null) {
+      for (const roleId of mentionedRoleIds) {
+        const role = pluginData.guild.roles.get(roleId);
+        if (role && !role.mentionable) {
+          await role.edit({
+            mentionable: true,
+          });
+          rolesMadeMentionable.push(role);
+        }
+      }
+    }
+
+    content.allowedMentions.everyone = false;
+  }
+
+  const createdMsg = await channel.createMessage(content, file);
+  pluginData.state.savedMessages.setPermanent(createdMsg.id);
+
+  for (const role of rolesMadeMentionable) {
+    role.edit({
+      mentionable: false,
+    });
+  }
+
+  if (downloadedAttachment) {
+    downloadedAttachment.deleteFn();
+  }
+
+  return createdMsg;
+}
diff --git a/backend/src/plugins/Post/util/scheduledPostLoop.ts b/backend/src/plugins/Post/util/scheduledPostLoop.ts
new file mode 100644
index 00000000..124af05b
--- /dev/null
+++ b/backend/src/plugins/Post/util/scheduledPostLoop.ts
@@ -0,0 +1,82 @@
+import { PluginData } from "knub";
+import { PostPluginType } from "../types";
+import { logger } from "src/logger";
+import { stripObjectToScalars, DBDateFormat, SECONDS } from "src/utils";
+import { LogType } from "src/data/LogType";
+import moment from "moment-timezone";
+import { TextChannel, User } from "eris";
+import { postMessage } from "./postMessage";
+
+const SCHEDULED_POST_CHECK_INTERVAL = 5 * SECONDS;
+
+export async function scheduledPostLoop(pluginData: PluginData<PostPluginType>) {
+  const duePosts = await pluginData.state.scheduledPosts.getDueScheduledPosts();
+  for (const post of duePosts) {
+    const channel = pluginData.guild.channels.get(post.channel_id);
+    if (channel instanceof TextChannel) {
+      const [username, discriminator] = post.author_name.split("#");
+      const author: Partial<User> = pluginData.client.users.get(post.author_id) || {
+        id: post.author_id,
+        username,
+        discriminator,
+      };
+
+      try {
+        const postedMessage = await postMessage(
+          pluginData,
+          channel,
+          post.content,
+          post.attachments,
+          post.enable_mentions,
+        );
+        pluginData.state.logs.log(LogType.POSTED_SCHEDULED_MESSAGE, {
+          author: stripObjectToScalars(author),
+          channel: stripObjectToScalars(channel),
+          messageId: postedMessage.id,
+        });
+      } catch (e) {
+        pluginData.state.logs.log(LogType.BOT_ALERT, {
+          body: `Failed to post scheduled message by {userMention(author)} to {channelMention(channel)}`,
+          channel: stripObjectToScalars(channel),
+          author: stripObjectToScalars(author),
+        });
+        logger.warn(
+          `Failed to post scheduled message to #${channel.name} (${channel.id}) on ${pluginData.guild.name} (${pluginData.guild.id})`,
+        );
+      }
+    }
+
+    let shouldClear = true;
+
+    if (post.repeat_interval) {
+      const nextPostAt = moment().add(post.repeat_interval, "ms");
+
+      if (post.repeat_until) {
+        const repeatUntil = moment(post.repeat_until, DBDateFormat);
+        if (nextPostAt.isSameOrBefore(repeatUntil)) {
+          await pluginData.state.scheduledPosts.update(post.id, {
+            post_at: nextPostAt.format(DBDateFormat),
+          });
+          shouldClear = false;
+        }
+      } else if (post.repeat_times) {
+        if (post.repeat_times > 1) {
+          await pluginData.state.scheduledPosts.update(post.id, {
+            post_at: nextPostAt.format(DBDateFormat),
+            repeat_times: post.repeat_times - 1,
+          });
+          shouldClear = false;
+        }
+      }
+    }
+
+    if (shouldClear) {
+      await pluginData.state.scheduledPosts.delete(post.id);
+    }
+  }
+
+  pluginData.state.scheduledPostLoopTimeout = setTimeout(
+    () => scheduledPostLoop(pluginData),
+    SCHEDULED_POST_CHECK_INTERVAL,
+  );
+}
diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts
index 32825725..10f343f9 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 { PostPlugin } from "./Post/PostPlugin";
 
 // prettier-ignore
 export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
@@ -20,6 +21,7 @@ export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
   LocateUserPlugin,
   PersistPlugin,
   PingableRolesPlugin,
+  PostPlugin,
   MessageSaverPlugin,
   NameHistoryPlugin,
   RemindersPlugin,