Repository: TheOdinProject/odin-bot-v2
Pull request #636 (2024-12-12 14:42:56)

!question: Use accessible link text label and unify contents with slash variant
<!-- Thank you for taking the time to contribute to The Odin Project. In order to get this pull request (PR) merged in a reasonable amount of time, you must complete this entire template. -->

## Because

The current `question` commands (both legacy and slash) use a bare URL for the question-asking guide. Like other links in other existing embeds, a masked link would allow for an accessible link text label.

## This PR
<!-- A bullet point list of one or more items describing the specific changes. -->
- Updates the `question` command embed content to use a masked link for the question-asking guide.
- Uses `lib/commandsContent.js` as `!question`'s source of truth.
- Updates `!question`'s snapshot.

## Issue
<!--
If this PR closes an open issue in this repo, replace the XXXXX below with the issue number, e.g. Closes #2013.

If this PR closes an open issue in another TOP repo, replace the #XXXXX with the URL of the issue, e.g. Closes https://github.com/TheOdinProject/curriculum/issues/XXXXX

If this PR does not close, but is related to another issue or PR, you can link it as above without the 'Closes' keyword, e.g. 'Related to #2013'.
-->
N/A

## Additional Information

![image](https://github.com/user-attachments/assets/e6ac153f-f8e6-43e8-a747-d9a1a3b01824)

## Pull Request Requirements
<!-- Replace the whitespace between the square brackets with an 'x', e.g. [x]. After you create the PR, they will become checkboxes that you can click on. -->
-   [x] I have thoroughly read and understand [The Odin Project Contributing Guide](https://github.com/TheOdinProject/.github/blob/main/CONTRIBUTING.md)
-   [x] The title of this PR follows the `location of change: brief description of change` format, e.g. `Callbacks command: Update verbiage`
-   [x] The `Because` section summarizes the reason for this PR
-   [x] The `This PR` section has a bullet point list describing the changes in this PR
-   [x] If this PR addresses an open issue, it is linked in the `Issue` section
-   [x] If this PR adds new features or functionality, I have added new tests
-   [x] If applicable, I have ensured all tests related to any command files included in this PR pass, and/or all snapshots are up to date



Patch:
diff --git a/botCommands/club_40_gifs.json b/botCommands/club_40_gifs.json
index 9184cbd..fa5dc0b 100644
--- a/botCommands/club_40_gifs.json
+++ b/botCommands/club_40_gifs.json
@@ -82,5 +82,9 @@
   {
     "gif": "https://i.imgur.com/ViaktVJ.gif",
     "author": "Mao"
+  },
+  {
+    "gif": "https://i.imgur.com/sDYeX6x.gif",
+    "author": "Zach"
   }
 ]
diff --git a/botCommands/question-legacy.js b/botCommands/question-legacy.js
index bf024cd..d1dae5f 100644
--- a/botCommands/question-legacy.js
+++ b/botCommands/question-legacy.js
@@ -1,24 +1,16 @@
 const Discord = require('discord.js');
 const { registerBotCommand } = require('../botEngine');
+const commandsContent = require('../lib/commandsContent');
+
+const { color, title, description } = commandsContent.question;
 
 const question = {
   regex: /(?<!\S)!question(?!\S)/,
   cb: () => {
     const questionEmbed = new Discord.EmbedBuilder()
-      .setColor('#cc9543')
-      .setTitle('Asking Great Questions')
-      .setDescription(`
-Asking context-rich questions makes it easy to receive help, and makes it easy for others to help you quickly! Great engineers ask great questions, and the prompt below is an invitation to improve your skills and set yourself up for success in the workplace. 
-
-**Project/Exercise:**
-**Lesson link:**
-**Code:** [code sandbox like replit or codepen]
-**Issue/Problem:** [screenshots if applicable]
-**What I expected:** 
-**What I've tried:** 
-
-For even more context around on how to hone your question-asking skills, give this a read: https://www.theodinproject.com/guides/community/how_to_ask
-              `);
+      .setColor(color)
+      .setTitle(title)
+      .setDescription(description);
 
     return { embeds: [questionEmbed] };
   },
diff --git a/config.js b/config.js
index f601257..d75a9f9 100644
--- a/config.js
+++ b/config.js
@@ -22,6 +22,10 @@ const config = {
     ContactModeratorsChannelId:
       process.env.CONTACT_MODERATORS_CHANNEL_ID || '1059513837197459547',
     rulesChannelId: process.env.RULES_CHANNEL_ID || '693244715839127653',
+    moderationLogChannelId:
+      process.env.MODERATION_LOG_CHANNEL_ID || '922520585018433536',
+    automodBlockChannelId:
+      process.env.AUTOMOD_BLOCK_CHANNEL_ID || '902580242881859654',
   },
   roles: {
     NOBOTRoleId: '783764176178774036',
diff --git a/lib/commandsContent.js b/lib/commandsContent.js
index 99c6056..efc0b5c 100644
--- a/lib/commandsContent.js
+++ b/lib/commandsContent.js
@@ -48,7 +48,7 @@ Asking context-rich questions makes it easy to receive help, and makes it easy f
 **What I expected:**
 **What I've tried:**
 
-For even more context around on how to hone your question-asking skills, give this a read: https://www.theodinproject.com/guides/community/how_to_ask
+For more information, read our [guide on honing your question-asking skills](<https://www.theodinproject.com/guides/community/how_to_ask>).
         `,
   },
   top: {
diff --git a/new-era-commands/context-menu/ban-spammer.js b/new-era-commands/context-menu/ban-spammer.js
new file mode 100644
index 0000000..c8137db
--- /dev/null
+++ b/new-era-commands/context-menu/ban-spammer.js
@@ -0,0 +1,16 @@
+const {
+  ContextMenuCommandBuilder,
+  ApplicationCommandType,
+  PermissionFlagsBits,
+} = require('discord.js');
+const SpamBanningService = require('../../services/spam-ban/spam-banning.service');
+
+module.exports = {
+  data: new ContextMenuCommandBuilder()
+    .setName('Ban Spammer')
+    .setType(ApplicationCommandType.Message)
+    .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
+  execute: async (interaction) => {
+    await SpamBanningService.handleInteraction(interaction);
+  },
+};
diff --git a/services/spam-ban/index.js b/services/spam-ban/index.js
new file mode 100644
index 0000000..66fdbf6
--- /dev/null
+++ b/services/spam-ban/index.js
@@ -0,0 +1,3 @@
+const SpamBanningService = require('./spam-banning.service');
+
+module.exports = SpamBanningService;
diff --git a/services/spam-ban/spam-banning.service.js b/services/spam-ban/spam-banning.service.js
new file mode 100644
index 0000000..340cc3f
--- /dev/null
+++ b/services/spam-ban/spam-banning.service.js
@@ -0,0 +1,108 @@
+const { EmbedBuilder } = require('discord.js');
+const { isAdmin } = require('../../utils/is-admin');
+const config = require('../../config');
+
+class SpamBanningService {
+  static async handleInteraction(interaction) {
+    const message = interaction.options.getMessage('message');
+
+    try {
+      let reply;
+
+      if (message.author.bot || isAdmin(message.member)) {
+        reply = 'You do not have the permission to ban this user';
+      } else if (message.channelId !== config.channels.automodBlockChannelId) {
+        reply = 'This command can only be used in the automod block channel.';
+      } else if (!message.member) {
+        message.react('❌');
+        reply = `Couldn't ban <@${message.author.id}>. User is not on the server.`;
+      } else {
+        reply = await SpamBanningService.#banUser(message);
+        await SpamBanningService.#announceBan(interaction, message);
+      }
+
+      interaction.reply({ content: reply, ephemeral: true });
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  static async #banUser(message) {
+    let reply = `Successfully banned <@${message.author.id}> for spam.`;
+    try {
+      // Make sure to send the message before banning otherwise user will not be found
+      await SpamBanningService.#sendMessageToUser(message.author);
+    } catch (error) {
+      reply = `Banned <@${message.author.id}> for spam but wasn't able to contact the user.`;
+    }
+
+    message.member.ban({ reason: 'Account is compromised' });
+    message.react('✅');
+    return reply;
+  }
+
+  static async #sendMessageToUser(author) {
+    const embedMessage = new EmbedBuilder()
+      .setTitle('Banned: Compromised account / Spam')
+      .setDescription(
+        `Your account has been banned from The Odin Project Discord server for sending spam. If this account is compromised, please follow the steps linked in this [Discord support article about securing your account](https://support.discord.com/hc/en-us/articles/24160905919511-My-Discord-Account-was-Hacked-or-Compromised).
+
+Once your account is secure, you may appeal your ban by emailing \`moderation@theodinproject.com\` with the following template:
+
+- Banned username:
+- Reason for ban:
+- Date of ban:
+- Steps taken to secure my account:
+- Additional comments (optional):
+
+Please note that it may take at least several days for our volunteer staff to process your request.`,
+      );
+
+    await author.send({
+      content:
+        'Your account has been banned. Please enable embeds in Discord settings if you cannot see the message below.',
+      embeds: [embedMessage],
+    });
+  }
+
+  static async #announceBan(interaction, message) {
+    const channelID = config.channels.moderationLogChannelId;
+    const channel = await interaction.guild.channels.fetch(channelID);
+    if (channel == null) {
+      throw new Error(`No channel with the ID ${channelID} was found.`);
+    }
+
+    const embed = {
+      timestamp: `${new Date().toISOString()}`,
+      color: 15747399,
+      footer: {
+        text: `ID: ${message.author.id}`,
+      },
+      author: {
+        name: `Ban | ${message.author.username}`,
+        icon_url: `${message.author.displayAvatarURL()}`,
+      },
+      fields: [
+        {
+          value: `<@${message.author.id}>`,
+          name: 'User',
+          inline: true,
+        },
+        {
+          value: `<@${interaction.user.id}>`,
+          name: 'Moderator',
+          inline: true,
+        },
+        {
+          value: 'Account is compromised and spamming phishing links.',
+          name: 'Reason',
+          inline: true,
+        },
+      ],
+    };
+
+    channel.send({ embeds: [embed] });
+  }
+}
+
+module.exports = SpamBanningService;


Test patch:
diff --git a/botCommands/__snapshots__/question-legacy.test.js.snap b/botCommands/__snapshots__/question-legacy.test.js.snap
index cc793f1..837c513 100644
--- a/botCommands/__snapshots__/question-legacy.test.js.snap
+++ b/botCommands/__snapshots__/question-legacy.test.js.snap
@@ -6,17 +6,17 @@ exports[`!question callback returns correct output 1`] = `
     {
       "color": 13407555,
       "description": "
-Asking context-rich questions makes it easy to receive help, and makes it easy for others to help you quickly! Great engineers ask great questions, and the prompt below is an invitation to improve your skills and set yourself up for success in the workplace. 
+Asking context-rich questions makes it easy to receive help, and makes it easy for others to help you quickly! Great engineers ask great questions, and the prompt below is an invitation to improve your skills and set yourself up for success in the workplace.
 
 **Project/Exercise:**
 **Lesson link:**
 **Code:** [code sandbox like replit or codepen]
 **Issue/Problem:** [screenshots if applicable]
-**What I expected:** 
-**What I've tried:** 
+**What I expected:**
+**What I've tried:**
 
-For even more context around on how to hone your question-asking skills, give this a read: https://www.theodinproject.com/guides/community/how_to_ask
-              ",
+For more information, read our [guide on honing your question-asking skills](<https://www.theodinproject.com/guides/community/how_to_ask>).
+        ",
       "title": "Asking Great Questions",
     },
   ],
diff --git a/services/spam-ban/__snapshots__/spam-banning.service.test.js.snap b/services/spam-ban/__snapshots__/spam-banning.service.test.js.snap
new file mode 100644
index 0000000..d8474f2
--- /dev/null
+++ b/services/spam-ban/__snapshots__/spam-banning.service.test.js.snap
@@ -0,0 +1,243 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Attempting to ban a bot or team member Does not ban admins 1`] = `
+{
+  "content": "You do not have the permission to ban this user",
+  "ephemeral": true,
+}
+`;
+
+exports[`Attempting to ban a bot or team member Does not ban bots 1`] = `
+{
+  "content": "You do not have the permission to ban this user",
+  "ephemeral": true,
+}
+`;
+
+exports[`Attempting to ban a bot or team member Does not ban core 1`] = `
+{
+  "content": "You do not have the permission to ban this user",
+  "ephemeral": true,
+}
+`;
+
+exports[`Attempting to ban a bot or team member Does not ban maintainers 1`] = `
+{
+  "content": "You do not have the permission to ban this user",
+  "ephemeral": true,
+}
+`;
+
+exports[`Attempting to ban a bot or team member Does not ban moderators 1`] = `
+{
+  "content": "You do not have the permission to ban this user",
+  "ephemeral": true,
+}
+`;
+
+exports[`Attempting to log banned user in moderation log channel Sends log to the correct channel 1`] = `
+{
+  "embeds": [
+    {
+      "author": {
+        "icon_url": "image.jpg",
+        "name": "Ban | bad.spammer",
+      },
+      "color": 15747399,
+      "fields": [
+        {
+          "inline": true,
+          "name": "User",
+          "value": "<@123>",
+        },
+        {
+          "inline": true,
+          "name": "Moderator",
+          "value": "<@007>",
+        },
+        {
+          "inline": true,
+          "name": "Reason",
+          "value": "Account is compromised and spamming phishing links.",
+        },
+      ],
+      "footer": {
+        "text": "ID: 123",
+      },
+      "timestamp": "2024-02-01T00:00:00.000Z",
+    },
+  ],
+}
+`;
+
+exports[`Banning spammer in different channels Ban user if in the automod channel 1`] = `
+{
+  "reason": "Account is compromised",
+}
+`;
+
+exports[`Banning spammer in different channels Ban user if in the automod channel 2`] = `
+{
+  "content": "Successfully banned <@123> for spam.",
+  "ephemeral": true,
+}
+`;
+
+exports[`Banning spammer in different channels Does not ban user in different channels other than automod 1`] = `
+{
+  "content": "This command can only be used in the automod block channel.",
+  "ephemeral": true,
+}
+`;
+
+exports[`Banning spammer in different channels Does not ban user in different channels other than automod 2`] = `
+{
+  "content": "This command can only be used in the automod block channel.",
+  "ephemeral": true,
+}
+`;
+
+exports[`Banning spammer in different channels Does not ban user in different channels other than automod 3`] = `
+{
+  "content": "This command can only be used in the automod block channel.",
+  "ephemeral": true,
+}
+`;
+
+exports[`Banning spammer that has left the server Reacts with the correct emoji to automod message 1`] = `"❌"`;
+
+exports[`Banning spammer that has left the server Sends back correct interaction reply to calling moderator 1`] = `
+{
+  "content": "Couldn't ban <@123>. User is not on the server.",
+  "ephemeral": true,
+}
+`;
+
+exports[`Banning spammer who has DM set to private Discord ban api is called with the correct reason 1`] = `
+{
+  "reason": "Account is compromised",
+}
+`;
+
+exports[`Banning spammer who has DM set to private Reacts with the correct emoji 1`] = `"✅"`;
+
+exports[`Banning spammer who has DM set to private Sends back correct interaction reply to calling moderator 1`] = `
+{
+  "content": "Banned <@123> for spam but wasn't able to contact the user.",
+  "ephemeral": true,
+}
+`;
+
+exports[`Banning spammer who has DM set to private Sends log to the correct channel 1`] = `
+{
+  "embeds": [
+    {
+      "author": {
+        "icon_url": "image.jpg",
+        "name": "Ban | bad.spammer",
+      },
+      "color": 15747399,
+      "fields": [
+        {
+          "inline": true,
+          "name": "User",
+          "value": "<@123>",
+        },
+        {
+          "inline": true,
+          "name": "Moderator",
+          "value": "<@007>",
+        },
+        {
+          "inline": true,
+          "name": "Reason",
+          "value": "Account is compromised and spamming phishing links.",
+        },
+      ],
+      "footer": {
+        "text": "ID: 123",
+      },
+      "timestamp": "2024-02-01T00:00:00.000Z",
+    },
+  ],
+}
+`;
+
+exports[`Banning spammer with DM enabled Discord ban api is called with the correct reason 1`] = `
+{
+  "reason": "Account is compromised",
+}
+`;
+
+exports[`Banning spammer with DM enabled Discord ban api is called with the correct reason 2`] = `
+{
+  "content": "Successfully banned <@123> for spam.",
+  "ephemeral": true,
+}
+`;
+
+exports[`Banning spammer with DM enabled Discord message api is called with the correct message 1`] = `
+{
+  "content": "Your account has been banned. Please enable embeds in Discord settings if you cannot see the message below.",
+  "embeds": [
+    {
+      "description": "Your account has been banned from The Odin Project Discord server for sending spam. If this account is compromised, please follow the steps linked in this [Discord support article about securing your account](https://support.discord.com/hc/en-us/articles/24160905919511-My-Discord-Account-was-Hacked-or-Compromised).
+
+Once your account is secure, you may appeal your ban by emailing \`moderation@theodinproject.com\` with the following template:
+
+- Banned username:
+- Reason for ban:
+- Date of ban:
+- Steps taken to secure my account:
+- Additional comments (optional):
+
+Please note that it may take at least several days for our volunteer staff to process your request.",
+      "title": "Banned: Compromised account / Spam",
+    },
+  ],
+}
+`;
+
+exports[`Banning spammer with DM enabled Reacts with the correct emoji 1`] = `"✅"`;
+
+exports[`Banning spammer with DM enabled Sends back correct interaction reply to calling moderator 1`] = `
+{
+  "content": "Successfully banned <@123> for spam.",
+  "ephemeral": true,
+}
+`;
+
+exports[`Banning spammer with DM enabled Sends log to the correct channel 1`] = `
+{
+  "embeds": [
+    {
+      "author": {
+        "icon_url": "image.jpg",
+        "name": "Ban | bad.spammer",
+      },
+      "color": 15747399,
+      "fields": [
+        {
+          "inline": true,
+          "name": "User",
+          "value": "<@123>",
+        },
+        {
+          "inline": true,
+          "name": "Moderator",
+          "value": "<@007>",
+        },
+        {
+          "inline": true,
+          "name": "Reason",
+          "value": "Account is compromised and spamming phishing links.",
+        },
+      ],
+      "footer": {
+        "text": "ID: 123",
+      },
+      "timestamp": "2024-02-01T00:00:00.000Z",
+    },
+  ],
+}
+`;
diff --git a/services/spam-ban/spam-banning.service.test.js b/services/spam-ban/spam-banning.service.test.js
new file mode 100644
index 0000000..206c054
--- /dev/null
+++ b/services/spam-ban/spam-banning.service.test.js
@@ -0,0 +1,347 @@
+const SpamBanningService = require('./spam-banning.service');
+const config = require('../../config');
+
+beforeAll(() => {
+  jest.useFakeTimers();
+  // Date.UTC Required so that test snippets match on different timezones
+  jest.setSystemTime(new Date(Date.UTC(2024, 1, 1)));
+});
+
+afterAll(() => {
+  jest.useRealTimers();
+});
+
+function createInteractionMock(message, guild) {
+  let replyArg;
+
+  return {
+    reply: jest.fn((arg) => {
+      replyArg = arg;
+    }),
+    guild,
+    message,
+
+    // The mod who initialized the interaction
+    user: {
+      id: '007',
+    },
+
+    // Used by service to retrieve the message
+    options: { getMessage: () => message },
+
+    getReplyArg: () => replyArg,
+    getAuthorSendArg: () => message.getSendArg(),
+    getBanArg: () => message.getBanArg(),
+    getReactArg: () => message.getReactArg(),
+    getChannelSendArg: () =>
+      guild.channels.cache
+        .find((c) => c.id === config.channels.moderationLogChannelId)
+        .getSendArg(),
+  };
+}
+
+function createMessageMock() {
+  let sendArg;
+  let banArg;
+  let reactArg;
+
+  return {
+    channelId: config.channels.automodBlockChannelId,
+    author: {
+      id: '123',
+      username: 'bad.spammer',
+      bot: false,
+      send: jest.fn((arg) => {
+        sendArg = arg;
+      }),
+      displayAvatarURL: () => 'image.jpg',
+    },
+    member: {
+      roles: {
+        cache: [],
+      },
+      ban: jest.fn((arg) => {
+        banArg = arg;
+      }),
+    },
+    react: jest.fn((arg) => {
+      reactArg = arg;
+    }),
+    getSendArg: () => sendArg,
+    getBanArg: () => banArg,
+    getReactArg: () => reactArg,
+  };
+}
+
+function createChannelMock(id) {
+  let sendArg;
+  return {
+    id,
+    send: jest.fn((arg) => {
+      sendArg = arg;
+    }),
+    getSendArg: () => sendArg,
+  };
+}
+
+function createGuildMock() {
+  const channels = {
+    cache: [
+      createChannelMock(config.channels.moderationLogChannelId),
+      createChannelMock(config.channels.automodBlockChannelId),
+      createChannelMock('101010'),
+      createChannelMock('22223333'),
+      createChannelMock('2302382'),
+      createChannelMock('000000'),
+    ],
+    fetch: (id) => channels.cache.find((c) => c.id === id),
+  };
+
+  return {
+    channels,
+  };
+}
+
+describe('Banning spammer in different channels', () => {
+  let interactionMock;
+  beforeEach(() => {
+    const messageMock = createMessageMock();
+    const guildMock = createGuildMock();
+    interactionMock = createInteractionMock(messageMock, guildMock);
+  });
+
+  it('Ban user if in the automod channel', async () => {
+    interactionMock.message.channelId = config.channels.automodBlockChannelId;
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.member.ban).toHaveBeenCalledTimes(1);
+    expect(interactionMock.getBanArg()).toMatchSnapshot();
+    expect(interactionMock.getReplyArg()).toMatchSnapshot();
+  });
+
+  it('Does not ban user in different channels other than automod', async () => {
+    interactionMock.message.channelId = null;
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.member.ban).not.toHaveBeenCalled();
+    expect(interactionMock.getReplyArg()).toMatchSnapshot();
+
+    interactionMock.message.channelId = config.channels.moderationLogChannelId;
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.member.ban).not.toHaveBeenCalled();
+    expect(interactionMock.getReplyArg()).toMatchSnapshot();
+
+    interactionMock.message.channelId = '21304782342';
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.member.ban).not.toHaveBeenCalled();
+    expect(interactionMock.getReplyArg()).toMatchSnapshot();
+  });
+});
+
+describe('Banning spammer with DM enabled', () => {
+  let interactionMock;
+  beforeEach(() => {
+    const messageMock = createMessageMock();
+    const guildMock = createGuildMock();
+    interactionMock = createInteractionMock(messageMock, guildMock);
+  });
+
+  it('Discord ban api is called with the correct reason', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.member.ban).toHaveBeenCalledTimes(1);
+    expect(interactionMock.getBanArg()).toMatchSnapshot();
+    expect(interactionMock.getReplyArg()).toMatchSnapshot();
+  });
+
+  it('Discord message api is called with the correct message', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.author.send).toHaveBeenCalledTimes(1);
+    expect(interactionMock.getAuthorSendArg()).toMatchSnapshot();
+  });
+
+  it('Reacts with the correct emoji', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.react).toHaveBeenCalledTimes(1);
+    expect(interactionMock.getReactArg()).toMatchSnapshot();
+  });
+
+  it('Sends log to the correct channel', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    interactionMock.guild.channels.cache.forEach((channel) => {
+      if (channel.id === config.channels.moderationLogChannelId) {
+        expect(channel.send).toHaveBeenCalledTimes(1);
+        expect(interactionMock.getChannelSendArg()).toMatchSnapshot();
+      } else {
+        expect(channel.send).not.toHaveBeenCalled();
+      }
+    });
+  });
+
+  it('Sends back correct interaction reply to calling moderator', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.getReplyArg()).toMatchSnapshot();
+  });
+});
+
+describe('Banning spammer who has DM set to private', () => {
+  let interactionMock;
+  beforeEach(() => {
+    const messageMock = createMessageMock();
+    const guildMock = createGuildMock();
+    messageMock.author.send = jest.fn(() => {
+      throw new Error("Can't contact user");
+    });
+    interactionMock = createInteractionMock(messageMock, guildMock);
+  });
+
+  it('Discord ban api is called with the correct reason', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.member.ban).toHaveBeenCalledTimes(1);
+    expect(interactionMock.getBanArg()).toMatchSnapshot();
+  });
+
+  it('Discord message api is called and error handled', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.author.send).toHaveBeenCalledTimes(1);
+  });
+
+  it('Reacts with the correct emoji', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.react).toHaveBeenCalledTimes(1);
+    expect(interactionMock.getReactArg()).toMatchSnapshot();
+  });
+
+  it('Sends log to the correct channel', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    interactionMock.guild.channels.cache.forEach((channel) => {
+      if (channel.id === config.channels.moderationLogChannelId) {
+        expect(channel.send).toHaveBeenCalledTimes(1);
+        expect(channel.getSendArg()).toMatchSnapshot();
+      } else {
+        expect(channel.send).not.toHaveBeenCalled();
+      }
+    });
+  });
+
+  it('Sends back correct interaction reply to calling moderator', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.getReplyArg()).toMatchSnapshot();
+  });
+});
+
+describe('Banning spammer that has left the server', () => {
+  let interactionMock;
+  beforeEach(() => {
+    const messageMock = createMessageMock();
+    const guildMock = createGuildMock();
+    messageMock.member = null;
+    interactionMock = createInteractionMock(messageMock, guildMock);
+  });
+
+  it('Author message sending api is not called', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.author.send).not.toHaveBeenCalled();
+  });
+
+  it('Reacts with the correct emoji to automod message', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.react).toHaveBeenCalledTimes(1);
+    expect(interactionMock.getReactArg()).toMatchSnapshot();
+  });
+
+  it('Does not log any channel', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    interactionMock.guild.channels.cache.forEach((channel) => {
+      expect(channel.send).not.toHaveBeenCalled();
+    });
+  });
+
+  it('Sends back correct interaction reply to calling moderator', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.getReplyArg()).toMatchSnapshot();
+  });
+});
+
+describe('Attempting to ban a bot or team member', () => {
+  let interactionMock;
+  beforeEach(() => {
+    const messageMock = createMessageMock();
+    const guildMock = createGuildMock();
+    interactionMock = createInteractionMock(messageMock, guildMock);
+  });
+
+  it('Does not ban bots', async () => {
+    interactionMock.message.author.bot = true;
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.member.ban).not.toHaveBeenCalled();
+    expect(interactionMock.message.author.send).not.toHaveBeenCalled();
+    expect(interactionMock.message.react).not.toHaveBeenCalled();
+    expect(interactionMock.getReplyArg()).toMatchSnapshot();
+  });
+
+  it('Does not ban moderators', async () => {
+    interactionMock.message.member.roles.cache = [{ name: 'moderator' }];
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.member.ban).not.toHaveBeenCalled();
+    expect(interactionMock.message.author.send).not.toHaveBeenCalled();
+    expect(interactionMock.message.react).not.toHaveBeenCalled();
+    expect(interactionMock.getReplyArg()).toMatchSnapshot();
+  });
+
+  it('Does not ban maintainers', async () => {
+    interactionMock.message.member.roles.cache = [{ name: 'maintainer' }];
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.member.ban).not.toHaveBeenCalled();
+    expect(interactionMock.message.author.send).not.toHaveBeenCalled();
+    expect(interactionMock.message.react).not.toHaveBeenCalled();
+    expect(interactionMock.getReplyArg()).toMatchSnapshot();
+  });
+
+  it('Does not ban core', async () => {
+    interactionMock.message.member.roles.cache = [{ name: 'core' }];
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.member.ban).not.toHaveBeenCalled();
+    expect(interactionMock.message.author.send).not.toHaveBeenCalled();
+    expect(interactionMock.message.react).not.toHaveBeenCalled();
+    expect(interactionMock.getReplyArg()).toMatchSnapshot();
+  });
+
+  it('Does not ban admins', async () => {
+    interactionMock.message.member.roles.cache = [{ name: 'admin' }];
+    await SpamBanningService.handleInteraction(interactionMock);
+    expect(interactionMock.message.member.ban).not.toHaveBeenCalled();
+    expect(interactionMock.message.author.send).not.toHaveBeenCalled();
+    expect(interactionMock.message.react).not.toHaveBeenCalled();
+    expect(interactionMock.getReplyArg()).toMatchSnapshot();
+  });
+});
+
+describe('Attempting to log banned user in moderation log channel', () => {
+  let interactionMock;
+  beforeEach(() => {
+    const messageMock = createMessageMock();
+    const guildMock = createGuildMock();
+    interactionMock = createInteractionMock(messageMock, guildMock);
+  });
+
+  it('Sends log to the correct channel', async () => {
+    await SpamBanningService.handleInteraction(interactionMock);
+    interactionMock.guild.channels.cache.forEach((channel) => {
+      if (channel.id === config.channels.moderationLogChannelId) {
+        expect(channel.send).toHaveBeenCalledTimes(1);
+        expect(interactionMock.getChannelSendArg()).toMatchSnapshot();
+      } else {
+        expect(channel.send).not.toHaveBeenCalled();
+      }
+    });
+  });
+
+  it("Error is handled if channel doesn't exist", async () => {
+    console.error = jest.fn();
+    interactionMock.guild.channels.fetch = async () => null;
+    await SpamBanningService.handleInteraction(interactionMock);
+    interactionMock.guild.channels.cache.forEach((channel) => {
+      expect(channel.send).not.toHaveBeenCalled();
+    });
+    expect(console.error).toHaveBeenCalledTimes(1);
+    console.error.mockClear();
+  });
+});