From 69c3896c5756cd28c86e73ae397231befdab4227 Mon Sep 17 00:00:00 2001
From: Dark <7890309+DarkView@users.noreply.github.com>
Date: Thu, 16 Jul 2020 21:41:50 +0200
Subject: [PATCH 1/4] Migrate UsernameSaver to new Plugin structure

---
 .../UsernameSaver/UsernameSaverPlugin.ts      | 21 +++++++++++++++++++
 .../UsernameSaver/events/MessageCreateEvt.ts  | 11 ++++++++++
 .../events/VoiceChannelJoinEvt.ts             | 11 ++++++++++
 backend/src/plugins/UsernameSaver/types.ts    | 12 +++++++++++
 .../plugins/UsernameSaver/updateUsername.ts   | 12 +++++++++++
 backend/src/plugins/availablePlugins.ts       |  2 ++
 6 files changed, 69 insertions(+)
 create mode 100644 backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts
 create mode 100644 backend/src/plugins/UsernameSaver/events/MessageCreateEvt.ts
 create mode 100644 backend/src/plugins/UsernameSaver/events/VoiceChannelJoinEvt.ts
 create mode 100644 backend/src/plugins/UsernameSaver/types.ts
 create mode 100644 backend/src/plugins/UsernameSaver/updateUsername.ts

diff --git a/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts b/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts
new file mode 100644
index 00000000..592258ea
--- /dev/null
+++ b/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts
@@ -0,0 +1,21 @@
+import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
+import { UsernameHistory } from "src/data/UsernameHistory";
+import { Queue } from "src/Queue";
+import { UsernameSaverPluginType } from "./types";
+import { MessageCreateEvt } from "./events/MessageCreateEvt";
+import { VoiceChannelJoinEvt } from "./events/VoiceChannelJoinEvt";
+
+export const UsernameSaverPlugin = zeppelinPlugin<UsernameSaverPluginType>()("username_saver", {
+  // prettier-ignore
+  events: [
+    MessageCreateEvt,
+    VoiceChannelJoinEvt,
+  ],
+
+  onLoad(pluginData) {
+    const { state, guild } = pluginData;
+
+    state.usernameHistory = new UsernameHistory();
+    state.updateQueue = new Queue();
+  },
+});
diff --git a/backend/src/plugins/UsernameSaver/events/MessageCreateEvt.ts b/backend/src/plugins/UsernameSaver/events/MessageCreateEvt.ts
new file mode 100644
index 00000000..14c6aded
--- /dev/null
+++ b/backend/src/plugins/UsernameSaver/events/MessageCreateEvt.ts
@@ -0,0 +1,11 @@
+import { usernameEvent } from "../types";
+import { updateUsername } from "../updateUsername";
+
+export const MessageCreateEvt = usernameEvent({
+  event: "messageCreate",
+
+  async listener(meta) {
+    if (meta.args.message.author.bot) return;
+    meta.pluginData.state.updateQueue.add(() => updateUsername(meta.pluginData, meta.args.message.author));
+  },
+});
diff --git a/backend/src/plugins/UsernameSaver/events/VoiceChannelJoinEvt.ts b/backend/src/plugins/UsernameSaver/events/VoiceChannelJoinEvt.ts
new file mode 100644
index 00000000..e26ea435
--- /dev/null
+++ b/backend/src/plugins/UsernameSaver/events/VoiceChannelJoinEvt.ts
@@ -0,0 +1,11 @@
+import { usernameEvent } from "../types";
+import { updateUsername } from "../updateUsername";
+
+export const VoiceChannelJoinEvt = usernameEvent({
+  event: "voiceChannelJoin",
+
+  async listener(meta) {
+    if (meta.args.member.bot) return;
+    meta.pluginData.state.updateQueue.add(() => updateUsername(meta.pluginData, meta.args.member.user));
+  },
+});
diff --git a/backend/src/plugins/UsernameSaver/types.ts b/backend/src/plugins/UsernameSaver/types.ts
new file mode 100644
index 00000000..eeb078e7
--- /dev/null
+++ b/backend/src/plugins/UsernameSaver/types.ts
@@ -0,0 +1,12 @@
+import { BasePluginType, eventListener } from "knub";
+import { UsernameHistory } from "src/data/UsernameHistory";
+import { Queue } from "src/Queue";
+
+export interface UsernameSaverPluginType extends BasePluginType {
+  state: {
+    usernameHistory: UsernameHistory;
+    updateQueue: Queue;
+  };
+}
+
+export const usernameEvent = eventListener<UsernameSaverPluginType>();
diff --git a/backend/src/plugins/UsernameSaver/updateUsername.ts b/backend/src/plugins/UsernameSaver/updateUsername.ts
new file mode 100644
index 00000000..6b5129a9
--- /dev/null
+++ b/backend/src/plugins/UsernameSaver/updateUsername.ts
@@ -0,0 +1,12 @@
+import { User } from "eris";
+import { PluginData } from "knub";
+import { UsernameSaverPluginType } from "./types";
+
+export async function updateUsername(pluginData: PluginData<UsernameSaverPluginType>, user: User) {
+  if (!user) return;
+  const newUsername = `${user.username}#${user.discriminator}`;
+  const latestEntry = await pluginData.state.usernameHistory.getLastEntry(user.id);
+  if (!latestEntry || newUsername !== latestEntry.username) {
+    await pluginData.state.usernameHistory.addEntry(user.id, newUsername);
+  }
+}
diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts
index 0dce5a2c..3e33f936 100644
--- a/backend/src/plugins/availablePlugins.ts
+++ b/backend/src/plugins/availablePlugins.ts
@@ -1,10 +1,12 @@
 import { UtilityPlugin } from "./Utility/UtilityPlugin";
 import { LocateUserPlugin } from "./LocateUser/LocateUserPlugin";
 import { ZeppelinPluginBlueprint } from "./ZeppelinPluginBlueprint";
+import { UsernameSaverPlugin } from "./UsernameSaver/UsernameSaverPlugin";
 
 // prettier-ignore
 export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
   LocateUserPlugin,
+  UsernameSaverPlugin,
   UtilityPlugin,
 ];
 

From e03f0006e7c18cc0a0ab2e028f5932f1a5334912 Mon Sep 17 00:00:00 2001
From: Nils <7890309+DarkView@users.noreply.github.com>
Date: Thu, 16 Jul 2020 22:35:10 +0200
Subject: [PATCH 2/4] Change rest to catchAll

---
 backend/src/plugins/LocateUser/commands/FollowCmd.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/backend/src/plugins/LocateUser/commands/FollowCmd.ts b/backend/src/plugins/LocateUser/commands/FollowCmd.ts
index d0e41a79..5d44a3e6 100644
--- a/backend/src/plugins/LocateUser/commands/FollowCmd.ts
+++ b/backend/src/plugins/LocateUser/commands/FollowCmd.ts
@@ -13,7 +13,7 @@ export const FollowCmd = locateUserCommand({
 
   signature: {
     member: ct.resolvedMember(),
-    reminder: ct.string({ required: false, rest: true }),
+    reminder: ct.string({ required: false, catchAll: true }),
 
     duration: ct.delay({ option: true, shortcut: "d" }),
     active: ct.bool({ option: true, shortcut: "a" }),

From 7ce1ebfee276fdc78f251724d22738e9a9693fb2 Mon Sep 17 00:00:00 2001
From: Dark <7890309+DarkView@users.noreply.github.com>
Date: Thu, 16 Jul 2020 22:54:02 +0200
Subject: [PATCH 3/4] Migrate Reminders to new Plugin structure

---
 .../src/plugins/Reminders/RemindersPlugin.ts  | 50 ++++++++++++++
 .../plugins/Reminders/commands/RemindCmd.ts   | 65 +++++++++++++++++++
 .../Reminders/commands/RemindersCmd.ts        | 31 +++++++++
 .../Reminders/commands/RemindersDeleteCmd.ts  | 29 +++++++++
 backend/src/plugins/Reminders/types.ts        | 22 +++++++
 .../Reminders/utils/postDueRemindersLoop.ts   | 48 ++++++++++++++
 backend/src/plugins/availablePlugins.ts       |  2 +
 7 files changed, 247 insertions(+)
 create mode 100644 backend/src/plugins/Reminders/RemindersPlugin.ts
 create mode 100644 backend/src/plugins/Reminders/commands/RemindCmd.ts
 create mode 100644 backend/src/plugins/Reminders/commands/RemindersCmd.ts
 create mode 100644 backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts
 create mode 100644 backend/src/plugins/Reminders/types.ts
 create mode 100644 backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts

diff --git a/backend/src/plugins/Reminders/RemindersPlugin.ts b/backend/src/plugins/Reminders/RemindersPlugin.ts
new file mode 100644
index 00000000..c183eb7d
--- /dev/null
+++ b/backend/src/plugins/Reminders/RemindersPlugin.ts
@@ -0,0 +1,50 @@
+import { PluginOptions } from "knub";
+import { ConfigSchema, RemindersPluginType } from "./types";
+import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
+import { GuildReminders } from "src/data/GuildReminders";
+import { postDueRemindersLoop } from "./utils/postDueRemindersLoop";
+import { RemindCmd } from "./commands/RemindCmd";
+import { RemindersCmd } from "./commands/RemindersCmd";
+import { RemindersDeleteCmd } from "./commands/RemindersDeleteCmd";
+
+const defaultOptions: PluginOptions<RemindersPluginType> = {
+  config: {
+    can_use: false,
+  },
+  overrides: [
+    {
+      level: ">=50",
+      config: {
+        can_use: true,
+      },
+    },
+  ],
+};
+
+export const RemindersPlugin = zeppelinPlugin<RemindersPluginType>()("reminders", {
+  configSchema: ConfigSchema,
+  defaultOptions,
+
+  // prettier-ignore
+  commands: [
+    RemindCmd,
+    RemindersCmd,
+    RemindersDeleteCmd,
+  ],
+
+  onLoad(pluginData) {
+    const { state, guild } = pluginData;
+
+    state.reminders = GuildReminders.getGuildInstance(guild.id);
+    state.tries = new Map();
+    state.unloaded = false;
+
+    state.postRemindersTimeout = null;
+    postDueRemindersLoop(pluginData);
+  },
+
+  onUnload(pluginData) {
+    clearTimeout(pluginData.state.postRemindersTimeout);
+    pluginData.state.unloaded = true;
+  },
+});
diff --git a/backend/src/plugins/Reminders/commands/RemindCmd.ts b/backend/src/plugins/Reminders/commands/RemindCmd.ts
new file mode 100644
index 00000000..faeacb70
--- /dev/null
+++ b/backend/src/plugins/Reminders/commands/RemindCmd.ts
@@ -0,0 +1,65 @@
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+import moment from "moment-timezone";
+import { convertDelayStringToMS } from "src/utils";
+import humanizeDuration from "humanize-duration";
+import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
+import { remindersCommand } from "../types";
+
+export const RemindCmd = remindersCommand({
+  trigger: ["remind", "remindme"],
+  usage: "!remind 3h Remind me of this in 3 hours please",
+  permission: "can_use",
+
+  signature: {
+    time: ct.string(),
+    reminder: ct.string({ required: false, catchAll: true }),
+  },
+
+  async run({ message: msg, args, pluginData }) {
+    const now = moment();
+
+    let reminderTime;
+    if (args.time.match(/^\d{4}-\d{1,2}-\d{1,2}$/)) {
+      // Date in YYYY-MM-DD format, remind at current time on that date
+      reminderTime = moment(args.time, "YYYY-M-D").set({
+        hour: now.hour(),
+        minute: now.minute(),
+        second: now.second(),
+      });
+    } else if (args.time.match(/^\d{4}-\d{1,2}-\d{1,2}T\d{2}:\d{2}$/)) {
+      // Date and time in YYYY-MM-DD[T]HH:mm format
+      reminderTime = moment(args.time, "YYYY-M-D[T]HH:mm").second(0);
+    } else {
+      // "Delay string" i.e. e.g. "2h30m"
+      const ms = convertDelayStringToMS(args.time);
+      if (ms === null) {
+        sendErrorMessage(pluginData, msg.channel, "Invalid reminder time");
+        return;
+      }
+
+      reminderTime = moment().add(ms, "millisecond");
+    }
+
+    if (!reminderTime.isValid() || reminderTime.isBefore(now)) {
+      sendErrorMessage(pluginData, msg.channel, "Invalid reminder time");
+      return;
+    }
+
+    const reminderBody = args.reminder || `https://discord.com/channels/${this.guildId}/${msg.channel.id}/${msg.id}`;
+    await pluginData.state.reminders.add(
+      msg.author.id,
+      msg.channel.id,
+      reminderTime.format("YYYY-MM-DD HH:mm:ss"),
+      reminderBody,
+      moment().format("YYYY-MM-DD HH:mm:ss"),
+    );
+
+    const msUntilReminder = reminderTime.diff(now);
+    const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true });
+    sendSuccessMessage(
+      pluginData,
+      msg.channel,
+      `I will remind you in **${timeUntilReminder}** at **${reminderTime.format("YYYY-MM-DD, HH:mm")}**`,
+    );
+  },
+});
diff --git a/backend/src/plugins/Reminders/commands/RemindersCmd.ts b/backend/src/plugins/Reminders/commands/RemindersCmd.ts
new file mode 100644
index 00000000..0273880d
--- /dev/null
+++ b/backend/src/plugins/Reminders/commands/RemindersCmd.ts
@@ -0,0 +1,31 @@
+import { remindersCommand } from "../types";
+import { sendErrorMessage } from "src/pluginUtils";
+import { sorter, createChunkedMessage } from "src/utils";
+import moment from "moment-timezone";
+import humanizeDuration from "humanize-duration";
+
+export const RemindersCmd = remindersCommand({
+  trigger: "reminders",
+  permission: "can_use",
+
+  async run({ message: msg, args, pluginData }) {
+    const reminders = await pluginData.state.reminders.getRemindersByUserId(msg.author.id);
+    if (reminders.length === 0) {
+      sendErrorMessage(pluginData, msg.channel, "No reminders");
+      return;
+    }
+
+    reminders.sort(sorter("remind_at"));
+    const longestNum = (reminders.length + 1).toString().length;
+    const lines = Array.from(reminders.entries()).map(([i, reminder]) => {
+      const num = i + 1;
+      const paddedNum = num.toString().padStart(longestNum, " ");
+      const target = moment(reminder.remind_at, "YYYY-MM-DD HH:mm:ss");
+      const diff = target.diff(moment());
+      const result = humanizeDuration(diff, { largest: 2, round: true });
+      return `\`${paddedNum}.\` \`${reminder.remind_at} (${result})\` ${reminder.body}`;
+    });
+
+    createChunkedMessage(msg.channel, lines.join("\n"));
+  },
+});
diff --git a/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts b/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts
new file mode 100644
index 00000000..e31677b4
--- /dev/null
+++ b/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts
@@ -0,0 +1,29 @@
+import { remindersCommand } from "../types";
+import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
+import { sorter } from "src/utils";
+import { commandTypeHelpers as ct } from "../../../commandTypes";
+
+export const RemindersDeleteCmd = remindersCommand({
+  trigger: ["reminders delete", "reminders d"],
+  permission: "can_use",
+
+  signature: {
+    num: ct.number(),
+  },
+
+  async run({ message: msg, args, pluginData }) {
+    const reminders = await pluginData.state.reminders.getRemindersByUserId(msg.author.id);
+    reminders.sort(sorter("remind_at"));
+    const lastNum = reminders.length + 1;
+
+    if (args.num > lastNum || args.num < 0) {
+      sendErrorMessage(pluginData, msg.channel, "Unknown reminder");
+      return;
+    }
+
+    const toDelete = reminders[args.num - 1];
+    await pluginData.state.reminders.delete(toDelete.id);
+
+    sendSuccessMessage(pluginData, msg.channel, "Reminder deleted");
+  },
+});
diff --git a/backend/src/plugins/Reminders/types.ts b/backend/src/plugins/Reminders/types.ts
new file mode 100644
index 00000000..8154e6a2
--- /dev/null
+++ b/backend/src/plugins/Reminders/types.ts
@@ -0,0 +1,22 @@
+import * as t from "io-ts";
+import { BasePluginType, command } from "knub";
+import { GuildReminders } from "src/data/GuildReminders";
+
+export const ConfigSchema = t.type({
+  can_use: t.boolean,
+});
+export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+
+export interface RemindersPluginType extends BasePluginType {
+  config: TConfigSchema;
+
+  state: {
+    reminders: GuildReminders;
+    tries: Map<number, number>;
+
+    postRemindersTimeout;
+    unloaded: boolean;
+  };
+}
+
+export const remindersCommand = command<RemindersPluginType>();
diff --git a/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts b/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts
new file mode 100644
index 00000000..18d94a2b
--- /dev/null
+++ b/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts
@@ -0,0 +1,48 @@
+import { TextChannel } from "eris";
+import { PluginData } from "knub";
+import { RemindersPluginType } from "../types";
+import moment from "moment-timezone";
+import humanizeDuration from "humanize-duration";
+import { disableLinkPreviews } from "knub/dist/helpers";
+import { SECONDS } from "src/utils";
+
+const REMINDER_LOOP_TIME = 10 * SECONDS;
+const MAX_TRIES = 3;
+
+export async function postDueRemindersLoop(pluginData: PluginData<RemindersPluginType>) {
+  const pendingReminders = await pluginData.state.reminders.getDueReminders();
+  for (const reminder of pendingReminders) {
+    const channel = pluginData.guild.channels.get(reminder.channel_id);
+    if (channel && channel instanceof TextChannel) {
+      try {
+        // Only show created at date if one exists
+        if (moment(reminder.created_at).isValid()) {
+          const target = moment();
+          const diff = target.diff(moment(reminder.created_at, "YYYY-MM-DD HH:mm:ss"));
+          const result = humanizeDuration(diff, { largest: 2, round: true });
+          await channel.createMessage(
+            disableLinkPreviews(
+              `Reminder for <@!${reminder.user_id}>: ${reminder.body} \n\`Set at ${reminder.created_at} (${result} ago)\``,
+            ),
+          );
+        } else {
+          await channel.createMessage(disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`));
+        }
+      } catch (e) {
+        // Probably random Discord internal server error or missing permissions or somesuch
+        // Try again next round unless we've already tried to post this a bunch of times
+        const tries = pluginData.state.tries.get(reminder.id) || 0;
+        if (tries < MAX_TRIES) {
+          pluginData.state.tries.set(reminder.id, tries + 1);
+          continue;
+        }
+      }
+    }
+
+    await pluginData.state.reminders.delete(reminder.id);
+  }
+
+  if (!pluginData.state.unloaded) {
+    pluginData.state.postRemindersTimeout = setTimeout(() => postDueRemindersLoop(pluginData), REMINDER_LOOP_TIME);
+  }
+}
diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts
index 0dce5a2c..16d5849c 100644
--- a/backend/src/plugins/availablePlugins.ts
+++ b/backend/src/plugins/availablePlugins.ts
@@ -1,10 +1,12 @@
 import { UtilityPlugin } from "./Utility/UtilityPlugin";
 import { LocateUserPlugin } from "./LocateUser/LocateUserPlugin";
 import { ZeppelinPluginBlueprint } from "./ZeppelinPluginBlueprint";
+import { RemindersPlugin } from "./Reminders/RemindersPlugin";
 
 // prettier-ignore
 export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
   LocateUserPlugin,
+  RemindersPlugin,
   UtilityPlugin,
 ];
 

From 4e2dd700300731e7117cd390b7ad6f29cad21762 Mon Sep 17 00:00:00 2001
From: Dark <7890309+DarkView@users.noreply.github.com>
Date: Thu, 16 Jul 2020 20:49:43 +0200
Subject: [PATCH 4/4] Migrate WelcomeMessage to new Plugin structure

---
 .../WelcomeMessage/WelcomeMessagePlugin.ts    | 29 +++++++++++
 .../events/GuildMemberAddEvt.ts               | 51 +++++++++++++++++++
 backend/src/plugins/WelcomeMessage/types.ts   | 20 ++++++++
 backend/src/plugins/availablePlugins.ts       |  2 +
 4 files changed, 102 insertions(+)
 create mode 100644 backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts
 create mode 100644 backend/src/plugins/WelcomeMessage/events/GuildMemberAddEvt.ts
 create mode 100644 backend/src/plugins/WelcomeMessage/types.ts

diff --git a/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts
new file mode 100644
index 00000000..b34e5659
--- /dev/null
+++ b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts
@@ -0,0 +1,29 @@
+import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
+import { PluginOptions } from "knub";
+import { WelcomeMessagePluginType, ConfigSchema } from "./types";
+import { GuildLogs } from "src/data/GuildLogs";
+import { GuildMemberAddEvt } from "./events/GuildMemberAddEvt";
+
+const defaultOptions: PluginOptions<WelcomeMessagePluginType> = {
+  config: {
+    send_dm: false,
+    send_to_channel: null,
+    message: "",
+  },
+};
+
+export const WelcomeMessagePlugin = zeppelinPlugin<WelcomeMessagePluginType>()("welcome_message", {
+  configSchema: ConfigSchema,
+  defaultOptions,
+
+  // prettier-ignore
+  events: [
+    GuildMemberAddEvt,
+  ],
+
+  onLoad(pluginData) {
+    const { state, guild } = pluginData;
+
+    state.logs = new GuildLogs(guild.id);
+  },
+});
diff --git a/backend/src/plugins/WelcomeMessage/events/GuildMemberAddEvt.ts b/backend/src/plugins/WelcomeMessage/events/GuildMemberAddEvt.ts
new file mode 100644
index 00000000..8e33080e
--- /dev/null
+++ b/backend/src/plugins/WelcomeMessage/events/GuildMemberAddEvt.ts
@@ -0,0 +1,51 @@
+import { welcomeEvent } from "../types";
+import { renderTemplate } from "src/templateFormatter";
+import { stripObjectToScalars, createChunkedMessage } from "src/utils";
+import { LogType } from "src/data/LogType";
+import { TextChannel } from "eris";
+
+export const GuildMemberAddEvt = welcomeEvent({
+  event: "guildMemberAdd",
+
+  async listener(meta) {
+    const pluginData = meta.pluginData;
+    const member = meta.args.member;
+
+    const config = pluginData.config.get();
+    if (!config.message) return;
+    if (!config.send_dm && !config.send_to_channel) return;
+
+    const formatted = await renderTemplate(config.message, {
+      member: stripObjectToScalars(member, ["user"]),
+    });
+
+    if (config.send_dm) {
+      const dmChannel = await member.user.getDMChannel();
+      if (!dmChannel) return;
+
+      try {
+        await createChunkedMessage(dmChannel, formatted);
+      } catch (e) {
+        pluginData.state.logs.log(LogType.BOT_ALERT, {
+          body: `Failed send a welcome DM to {userMention(member)}`,
+          member: stripObjectToScalars(member),
+        });
+      }
+    }
+
+    if (config.send_to_channel) {
+      const channel = meta.args.guild.channels.get(config.send_to_channel);
+      if (!channel || !(channel instanceof TextChannel)) return;
+
+      try {
+        await createChunkedMessage(channel, formatted);
+      } catch (e) {
+        pluginData.state.logs.log(LogType.BOT_ALERT, {
+          body: `Failed send a welcome message for {userMention(member)} to {channelMention(channel)}`,
+          member: stripObjectToScalars(member),
+          channel: stripObjectToScalars(channel),
+        });
+      }
+    }
+  },
+});
diff --git a/backend/src/plugins/WelcomeMessage/types.ts b/backend/src/plugins/WelcomeMessage/types.ts
new file mode 100644
index 00000000..eef61be9
--- /dev/null
+++ b/backend/src/plugins/WelcomeMessage/types.ts
@@ -0,0 +1,20 @@
+import * as t from "io-ts";
+import { BasePluginType, eventListener } from "knub";
+import { tNullable } from "src/utils";
+import { GuildLogs } from "src/data/GuildLogs";
+
+export const ConfigSchema = t.type({
+  send_dm: t.boolean,
+  send_to_channel: tNullable(t.string),
+  message: t.string,
+});
+export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+
+export interface WelcomeMessagePluginType extends BasePluginType {
+  config: TConfigSchema;
+  state: {
+    logs: GuildLogs;
+  };
+}
+
+export const welcomeEvent = eventListener<WelcomeMessagePluginType>();
diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts
index 0dce5a2c..099c7bcf 100644
--- a/backend/src/plugins/availablePlugins.ts
+++ b/backend/src/plugins/availablePlugins.ts
@@ -1,11 +1,13 @@
 import { UtilityPlugin } from "./Utility/UtilityPlugin";
 import { LocateUserPlugin } from "./LocateUser/LocateUserPlugin";
 import { ZeppelinPluginBlueprint } from "./ZeppelinPluginBlueprint";
+import { WelcomeMessagePlugin } from "./WelcomeMessage/WelcomeMessagePlugin";
 
 // prettier-ignore
 export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
   LocateUserPlugin,
   UtilityPlugin,
+  WelcomeMessagePlugin,
 ];
 
 export const globalPlugins = [];