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 00000000..c8137dbd --- /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/__snapshots__/spam-banning.service.test.js.snap b/services/spam-ban/__snapshots__/spam-banning.service.test.js.snap new file mode 100644 index 00000000..9f616d12 --- /dev/null +++ b/services/spam-ban/__snapshots__/spam-banning.service.test.js.snap @@ -0,0 +1,202 @@ +// 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 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 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/index.js b/services/spam-ban/index.js new file mode 100644 index 00000000..66fdbf60 --- /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 00000000..3de72927 --- /dev/null +++ b/services/spam-ban/spam-banning.service.js @@ -0,0 +1,111 @@ +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'); + if (message.author.bot || isAdmin(message.member)) { + interaction.reply({ + content: 'You do not have the permission to ban this user', + ephemeral: true, + }); + return; + } + + try { + let reply; + + 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; 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 00000000..81f6cc23 --- /dev/null +++ b/services/spam-ban/spam-banning.service.test.js @@ -0,0 +1,311 @@ +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 { + 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 channelId = config.channels.moderationLogChannelId; + const moderationLogChannel = createChannelMock(channelId); + return { + channels: { + cache: [ + createChannelMock('2342314'), + createChannelMock('101010'), + createChannelMock('22223333'), + moderationLogChannel, + createChannelMock('2302382'), + createChannelMock('000000'), + ], + fetch: async (id) => (id === channelId ? moderationLogChannel : null), + }, + }; +} + +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(); + }); + + 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.toHaveBeenCalledTimes(1); + } + }); + }); + + 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.toHaveBeenCalledTimes(1); + } + }); + }); + + 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.toHaveBeenCalledTimes(1); + } + }); + }); + + 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.toHaveBeenCalledTimes(1); + }); + expect(console.error).toHaveBeenCalledTimes(1); + console.error.mockClear(); + }); +});