Repository: TheOdinProject/odin-bot-v2
Pull request #630 (2024-11-18 23:43:58)

Chore(deps): Bump cross-spawn from 7.0.3 to 7.0.6
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md">cross-spawn's changelog</a>.</em></p>
<blockquote>
<h3><a href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.5...v7.0.6">7.0.6</a> (2024-11-18)</h3>
<h3>Bug Fixes</h3>
<ul>
<li>update cross-spawn version to 7.0.5 in package-lock.json (<a href="https://github.com/moxystudio/node-cross-spawn/commit/f700743918d901eff92960e15a8dd68f87bd4176">f700743</a>)</li>
</ul>
<h3><a href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.4...v7.0.5">7.0.5</a> (2024-11-07)</h3>
<h3>Bug Fixes</h3>
<ul>
<li>fix escaping bug introduced by backtracking (<a href="https://github.com/moxystudio/node-cross-spawn/commit/640d391fde65388548601d95abedccc12943374f">640d391</a>)</li>
</ul>
<h3><a href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.4">7.0.4</a> (2024-11-07)</h3>
<h3>Bug Fixes</h3>
<ul>
<li>disable regexp backtracking (<a href="https://redirect.github.com/moxystudio/node-cross-spawn/issues/160">#160</a>) (<a href="https://github.com/moxystudio/node-cross-spawn/commit/5ff3a07d9add449021d806e45c4168203aa833ff">5ff3a07</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/77cd97f3ca7b62c904a63a698fc4a79bf41977d0"><code>77cd97f</code></a> chore(release): 7.0.6</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/6717de49ff1e5de49622488dcb9c33fb25370c85"><code>6717de4</code></a> chore: upgrade standard-version</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/f700743918d901eff92960e15a8dd68f87bd4176"><code>f700743</code></a> fix: update cross-spawn version to 7.0.5 in package-lock.json</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/9a7e3b2165917367f74b8365faad9873b30d7263"><code>9a7e3b2</code></a> chore: fix build status badge</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/085268352dcbcad8064c64c5efb25268b4023184"><code>0852683</code></a> chore(release): 7.0.5</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/640d391fde65388548601d95abedccc12943374f"><code>640d391</code></a> fix: fix escaping bug introduced by backtracking</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/bff0c87c8b627c4e6d04ec2449e733048bebb464"><code>bff0c87</code></a> chore: remove codecov</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/a7c6abc6fee79641d45b452fe6217deaa1bd0973"><code>a7c6abc</code></a> chore: replace travis with github workflows</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/9b9246e0969e86656d7ccd527716bc3c18842a19"><code>9b9246e</code></a> chore(release): 7.0.4</li>
<li><a href="https://github.com/moxystudio/node-cross-spawn/commit/5ff3a07d9add449021d806e45c4168203aa833ff"><code>5ff3a07</code></a> fix: disable regexp backtracking (<a href="https://redirect.github.com/moxystudio/node-cross-spawn/issues/160">#160</a>)</li>
<li>Additional commits viewable in <a href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cross-spawn&package-manager=npm_and_yarn&previous-version=7.0.3&new-version=7.0.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/TheOdinProject/odin-bot-v2/network/alerts).

</details>


Patch:
diff --git a/botCommands/club_40_gifs.json b/botCommands/club_40_gifs.json
index d50dba9..974c754 100644
--- a/botCommands/club_40_gifs.json
+++ b/botCommands/club_40_gifs.json
@@ -66,5 +66,29 @@
   {
     "gif": "https://i.imgur.com/EhZCIzv.gif",
     "author": "Mao"
+  },
+  {
+    "gif": "https://i.imgur.com/3I613GG.gif",
+    "author": "Mao"
+  },
+  {
+    "gif": "https://i.imgur.com/mg5xPTM.gif",
+    "author": "Mao"
+  },
+  {
+    "gif": "https://i.imgur.com/5eEC2ra.gif",
+    "author": "Mao"
+  },
+  {
+    "gif": "https://i.imgur.com/ViaktVJ.gif",
+    "author": "Mao"
+  },
+  {
+    "gif": "https://i.imgur.com/sDYeX6x.gif",
+    "author": "Zach"
+  },
+  {
+    "gif": "https://i.imgur.com/xP2KXRY.gif",
+    "author": "Mao"
   }
 ]
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/new-era-commands/slash/college-students.js b/new-era-commands/slash/college-students.js
new file mode 100644
index 0000000..c6117b1
--- /dev/null
+++ b/new-era-commands/slash/college-students.js
@@ -0,0 +1,39 @@
+const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
+
+module.exports = {
+  data: new SlashCommandBuilder()
+    .setName('college-students')
+    .setDescription(
+      'College/University students seeking career advice, internship advice, and course planning',
+    )
+    .addUserOption((option) =>
+      option.setName('user').setDescription('user to ping'),
+    ),
+  execute: async (interaction) => {
+    const userId = interaction.options.getUser('user');
+
+    const collegeStudentEmbed = new EmbedBuilder()
+      .setColor('#cc9543')
+      .setTitle(
+        'College/University students seeking career advice, internship advice, and course planning:',
+      )
+      .setDescription(' ')
+      .addFields([
+        {
+          name: 'Career Counselors, Academic Advisors, and Professors:',
+          value: `- Career counselors have data on successful students' paths (resumes, GPA, coursework, activities) specific to your school/major. They know what students are doing to get hired. \n - Career counselors are aware of school-specific and major-specific internships and networking opportunities like connections to alumni who are in the field. \n - Career counselors maintain direct relationships with companies that hire from your school and know exactly what they want. They have meetings with hiring managers and get feedback/insight from those hiring managers that they can pass along to you.\n - Academic Advisors and Professors understand the majors and courses available to you. We can only guess.`,
+          inline: false,
+        },
+        {
+          name: 'Your tuition pays for those valuable resources - use them!',
+          value: `- Career counselors, academic advisors, and professors are paid professionals whose job is to guide students like you to success. Your tuition already pays for this premium support team. Meanwhile, 99.73% of people here are learning programming fundamentals and won't have context on your courses, your school, your major, or opportunities available to you. The staff at your school have all that.`,
+          inline: false,
+        },
+      ]);
+
+    await interaction.reply({
+      content: userId ? `${userId}` : '',
+      embeds: [collegeStudentEmbed],
+    });
+  },
+};
diff --git a/new-era-commands/slash/stuck.js b/new-era-commands/slash/stuck.js
index b175e9d..95ddd70 100644
--- a/new-era-commands/slash/stuck.js
+++ b/new-era-commands/slash/stuck.js
@@ -15,6 +15,8 @@ module.exports = {
 Have a look through the following flowchart (click to enlarge) for guidance in how to approach new concepts and problems. Adopting this process can make problem solving much more productive in the long-term!
 
 If you would like to ask for help, there is a very good question template you can find via the \`/question\` command which can help you include as much relevant context and structure your question in a way that makes it much easier for others to assist you.
+
+Make sure you've checked out our [guide to helping yourself before asking for help](<https://www.theodinproject.com/guides/community/before_asking>) to ensure you've done as much as you can first.
       `)
       .setImage(
         'https://cdn.statically.io/gh/TheOdinProject/curriculum/978ba0e91a6a47452de819246f4c495e6b092f54/foundations/introduction/motivation_and_mindset/imgs/00.png'
diff --git a/package-lock.json b/package-lock.json
index 3fc752b..8ec47cc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3598,9 +3598,9 @@
       }
     },
     "node_modules/cross-spawn": {
-      "version": "7.0.3",
-      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
-      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
       "dependencies": {
         "path-key": "^3.1.0",
         "shebang-command": "^2.0.0",
@@ -11247,9 +11247,9 @@
       }
     },
     "cross-spawn": {
-      "version": "7.0.3",
-      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
-      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
       "requires": {
         "path-key": "^3.1.0",
         "shebang-command": "^2.0.0",
diff --git a/services/moderation-message-delete.service.js b/services/moderation-message-delete.service.js
index be912e1..62019c0 100644
--- a/services/moderation-message-delete.service.js
+++ b/services/moderation-message-delete.service.js
@@ -7,7 +7,26 @@ class ModerationMessageDeleteService {
   static async handleInteraction(interaction) {
     const message = interaction.options.getMessage('message');
 
+    if (!message || !message.deletable) {
+      await ModerationMessageDeleteService.#interactionReply(interaction, {
+        content: 'Message cannot be deleted. It may have been deleted already.',
+        ephemeral: true,
+      });
+      return;
+    }
+
     await ModerationMessageDeleteService.#deleteMessage(message);
+
+    // For automod or system messages, just delete without notification
+    if (message.system || message.author.id === interaction.client.user.id) {
+      await ModerationMessageDeleteService.#interactionReply(interaction, {
+        content:
+          'Automod message deleted. This action did not affect the message that was flagged/blocked by Automod. You would want to use the app-delete command on the message directly to have the message deleted and member notified.',
+        ephemeral: true,
+      });
+      return;
+    }
+
     await ModerationMessageDeleteService.#interactionReply(interaction, {
       content: 'Message deleted.',
       ephemeral: true,
@@ -71,15 +90,21 @@ class ModerationMessageDeleteService {
   }
 
   static async #messageBuilder(message) {
-    return new EmbedBuilder()
-      .setTitle('Message Deleted')
-      .setDescription(
-        `
+    // Get content from either message content or embeds
+    let messageContent = message.content;
+
+    // If no content but has embeds, use the embed description
+    if (!messageContent && message.embeds?.length > 0) {
+      messageContent = message.embeds[0].description || 'Automod Message';
+    }
+
+    const embed = new EmbedBuilder().setTitle('Message Deleted').setDescription(
+      `
       Hi <@${message.author.id}>, your message in <#${message.channel.id}> was deleted by a moderator.
 
 Some common reasons for post removal include but isn't limited to;
 - the post was not in line with our discord rules, channel description or community expectations
-- the post didn't follow the specific forum channel guidelines (i.e. feedback trading)
+- the post didn't follow the specific forum channel guidelines
 - the post had downloadable files attached
 - a link was shared without context
 - the post was shared in multiple channels within a short timeframe (cross-posting)
@@ -90,8 +115,14 @@ Please make sure to check the rules of the server and the description of the cha
 You can find our rules including a link to the additional community expectations here <#${channels.rulesChannelId}>
 
 If after reading the rules, channel description or community expectations, you feel this deletion was made in error, feel free to send a dm to <@${modmailUserId}>`,
-      )
-      .setFields([{ name: 'Message:', value: message.content }]);
+    );
+
+    // Only add the field if we have content to show
+    if (messageContent?.trim()) {
+      embed.setFields([{ name: 'Message:', value: messageContent }]);
+    }
+
+    return embed;
   }
 }
 
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();
+  });
+});