From 5cc55db15ed1405b1a62584729215e30beab3c16 Mon Sep 17 00:00:00 2001 From: Asartea <76259120+Asartea@users.noreply.github.com> Date: Fri, 21 Mar 2025 14:30:37 +0100 Subject: [PATCH 1/3] Feat: Also allow banning spammers outside automod channel --- .../spam-banning.service.test.js.snap | 210 ++++++++++++++++-- services/spam-ban/spam-banning.service.js | 128 +++++++++-- .../spam-ban/spam-banning.service.test.js | 68 +++++- 3 files changed, 365 insertions(+), 41 deletions(-) diff --git a/services/spam-ban/__snapshots__/spam-banning.service.test.js.snap b/services/spam-ban/__snapshots__/spam-banning.service.test.js.snap index ca737832..09a31486 100644 --- a/services/spam-ban/__snapshots__/spam-banning.service.test.js.snap +++ b/services/spam-ban/__snapshots__/spam-banning.service.test.js.snap @@ -3,35 +3,35 @@ 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, + "flags": 64, } `; 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, + "flags": 64, } `; 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, + "flags": 64, } `; 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, + "flags": 64, } `; 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, + "flags": 64, } `; @@ -70,37 +70,211 @@ exports[`Attempting to log banned user in moderation log channel Sends log to th } `; -exports[`Banning spammer in different channels Ban user if in the automod channel 1`] = ` +exports[`Banning spammer in automod channel Ban user if in the automod channel 1`] = ` { + "deleteMessageSeconds": 0, "reason": "Account is compromised", } `; -exports[`Banning spammer in different channels Ban user if in the automod channel 2`] = ` +exports[`Banning spammer in automod channel Ban user if in the automod channel 2`] = ` { "content": "Successfully banned <@123> for spam.", + "flags": 64, +} +`; + +exports[`Banning spammer in other channels Asks for confirmation if not in automod channel 1`] = ` +{ + "components": [ + { + "components": [ + { + "custom_id": "dontDeleteMessages", + "emoji": undefined, + "label": "Don't delete messages", + "style": 4, + "type": 2, + }, + { + "custom_id": "deleteMessages", + "emoji": undefined, + "label": "Delete messages", + "style": 4, + "type": 2, + }, + { + "custom_id": "cancel", + "emoji": undefined, + "label": "Cancel", + "style": 2, + "type": 2, + }, + ], + "type": 1, + }, + ], + "content": "Would you like to delete messages from the user being banned?", + "ephemeral": true, + "withReponse": true, +} +`; + +exports[`Banning spammer in other channels Bans user and deletes their messages if delete message button clicked 1`] = ` +{ + "deleteMessageSeconds": 604800, + "reason": "Account is compromised", +} +`; + +exports[`Banning spammer in other channels Bans user and deletes their messages if delete message button clicked 2`] = ` +{ + "components": [ + { + "components": [ + { + "custom_id": "dontDeleteMessages", + "emoji": undefined, + "label": "Don't delete messages", + "style": 4, + "type": 2, + }, + { + "custom_id": "deleteMessages", + "emoji": undefined, + "label": "Delete messages", + "style": 4, + "type": 2, + }, + { + "custom_id": "cancel", + "emoji": undefined, + "label": "Cancel", + "style": 2, + "type": 2, + }, + ], + "type": 1, + }, + ], + "content": "Would you like to delete messages from the user being banned?", "ephemeral": true, + "withReponse": true, } `; -exports[`Banning spammer in different channels Does not ban user in different channels other than automod 1`] = ` +exports[`Banning spammer in other channels Bans user and does not delete their messages if keep message button clicked 1`] = ` { - "content": "This command can only be used in the automod block channel.", + "deleteMessageSeconds": 0, + "reason": "Account is compromised", +} +`; + +exports[`Banning spammer in other channels Bans user and does not delete their messages if keep message button clicked 2`] = ` +{ + "components": [ + { + "components": [ + { + "custom_id": "dontDeleteMessages", + "emoji": undefined, + "label": "Don't delete messages", + "style": 4, + "type": 2, + }, + { + "custom_id": "deleteMessages", + "emoji": undefined, + "label": "Delete messages", + "style": 4, + "type": 2, + }, + { + "custom_id": "cancel", + "emoji": undefined, + "label": "Cancel", + "style": 2, + "type": 2, + }, + ], + "type": 1, + }, + ], + "content": "Would you like to delete messages from the user being banned?", "ephemeral": true, + "withReponse": true, } `; -exports[`Banning spammer in different channels Does not ban user in different channels other than automod 2`] = ` +exports[`Banning spammer in other channels Cancels the action if the cancel button is clicked 1`] = ` { - "content": "This command can only be used in the automod block channel.", + "components": [ + { + "components": [ + { + "custom_id": "dontDeleteMessages", + "emoji": undefined, + "label": "Don't delete messages", + "style": 4, + "type": 2, + }, + { + "custom_id": "deleteMessages", + "emoji": undefined, + "label": "Delete messages", + "style": 4, + "type": 2, + }, + { + "custom_id": "cancel", + "emoji": undefined, + "label": "Cancel", + "style": 2, + "type": 2, + }, + ], + "type": 1, + }, + ], + "content": "Would you like to delete messages from the user being banned?", "ephemeral": true, + "withReponse": true, } `; -exports[`Banning spammer in different channels Does not ban user in different channels other than automod 3`] = ` +exports[`Banning spammer in other channels Cancels the action if the response times out 1`] = ` { - "content": "This command can only be used in the automod block channel.", + "components": [ + { + "components": [ + { + "custom_id": "dontDeleteMessages", + "emoji": undefined, + "label": "Don't delete messages", + "style": 4, + "type": 2, + }, + { + "custom_id": "deleteMessages", + "emoji": undefined, + "label": "Delete messages", + "style": 4, + "type": 2, + }, + { + "custom_id": "cancel", + "emoji": undefined, + "label": "Cancel", + "style": 2, + "type": 2, + }, + ], + "type": 1, + }, + ], + "content": "Would you like to delete messages from the user being banned?", "ephemeral": true, + "withReponse": true, } `; @@ -109,12 +283,13 @@ exports[`Banning spammer that has left the server Reacts with the correct emoji exports[`Banning spammer that has left the server Sends back correct interaction reply to calling moderator 1`] = ` { "content": "Banned <@123> for spam but wasn't able to contact the user as they have left the server.", - "ephemeral": true, + "flags": 64, } `; exports[`Banning spammer who has DM set to private Discord ban api is called with the correct reason 1`] = ` { + "deleteMessageSeconds": 0, "reason": "Account is compromised", } `; @@ -124,7 +299,7 @@ exports[`Banning spammer who has DM set to private Reacts with the correct emoji 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, + "flags": 64, } `; @@ -165,6 +340,7 @@ exports[`Banning spammer who has DM set to private Sends log to the correct chan exports[`Banning spammer with DM enabled Discord ban api is called with the correct reason 1`] = ` { + "deleteMessageSeconds": 0, "reason": "Account is compromised", } `; @@ -172,7 +348,7 @@ exports[`Banning spammer with DM enabled Discord ban api is called with the corr exports[`Banning spammer with DM enabled Discord ban api is called with the correct reason 2`] = ` { "content": "Successfully banned <@123> for spam.", - "ephemeral": true, + "flags": 64, } `; @@ -203,7 +379,7 @@ 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, + "flags": 64, } `; diff --git a/services/spam-ban/spam-banning.service.js b/services/spam-ban/spam-banning.service.js index f862d7ed..ead88f7d 100644 --- a/services/spam-ban/spam-banning.service.js +++ b/services/spam-ban/spam-banning.service.js @@ -1,4 +1,10 @@ -const { EmbedBuilder } = require('discord.js'); +const { + EmbedBuilder, + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, + MessageFlags, +} = require('discord.js'); const { isAdmin } = require('../../utils/is-admin'); const config = require('../../config'); @@ -6,35 +12,123 @@ class SpamBanningService { static async handleInteraction(interaction) { const message = interaction.options.getMessage('message'); - try { - if (message.author.bot || isAdmin(message.member)) { - interaction.reply({ - content: 'You do not have the permission to ban this user', - ephemeral: true, + if (message.author.bot || isAdmin(message.member)) { + interaction.reply({ + content: 'You do not have the permission to ban this user', + flags: MessageFlags.Ephemeral, + }); + return; + } + + let deleteMessages = false; + + if (message.channelId !== config.channels.automodBlockChannelId) { + const response = + await SpamBanningService.#AskForDeleteMessages(interaction); + + if (response.result === 'cancel') { + await interaction.editReply({ + content: 'Action has been cancelled.', + flags: MessageFlags.Ephemeral, + components: [], }); return; } - if (message.channelId !== config.channels.automodBlockChannelId) { - interaction.reply({ - content: - 'This command can only be used in the automod block channel.', - ephemeral: true, + if (response.result === 'timeout') { + await interaction.editReply({ + content: 'Action has been cancelled as you did not reply in time.', + flags: MessageFlags.Ephemeral, + components: [], }); return; } - const reply = await SpamBanningService.#banUser(interaction); - interaction.reply({ content: reply, ephemeral: true }); - await SpamBanningService.#announceBan(interaction, message); + + deleteMessages = response.result === 'deleteMessages'; + + if (!deleteMessages) { + // still delete the message that triggered the interaction. + await message.delete(); + } + } + + try { + const reply = await SpamBanningService.#banUser( + interaction, + deleteMessages, + ); + + if (message.channelId === config.channels.automodBlockChannelId) { + message.react('✅'); + await interaction.reply({ + content: reply, + flags: MessageFlags.Ephemeral, + }); + } + + await interaction.editReply({ + content: reply, + flags: MessageFlags.Ephemeral, + components: [], + }); + await SpamBanningService.#announceBan( + interaction, + message, + deleteMessages, + ); } catch (error) { console.error(error); } } - static async #banUser(interaction) { + static async #AskForDeleteMessages(interaction) { + const dontDeleteMessages = new ButtonBuilder() + .setCustomId('dontDeleteMessages') + .setLabel("Don't delete messages") + .setStyle(ButtonStyle.Danger); + + const deleteMessages = new ButtonBuilder() + .setCustomId('deleteMessages') + .setLabel('Delete messages') + .setStyle(ButtonStyle.Danger); + + const cancel = new ButtonBuilder() + .setCustomId('cancel') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary); + + const row = new ActionRowBuilder().addComponents( + dontDeleteMessages, + deleteMessages, + cancel, + ); + + const response = await interaction.reply({ + content: 'Would you like to delete messages from the user being banned?', + components: [row], + ephemeral: true, + withReponse: true, + }); + + try { + const option = await response.awaitMessageComponent({ time: 60_000 }); + + if (option.customId === 'deleteMessages') { + return { result: 'deleteMessages' }; + } + if (option.customId === 'dontDeleteMessages') { + return { result: 'dontDeleteMessages' }; + } + return { result: 'cancel' }; + } catch { + return { result: 'timeout' }; + } + } + + static async #banUser(interaction, deleteMessages) { const message = interaction.options.getMessage('message'); const { guild } = interaction; let reply = `Successfully banned <@${message.author.id}> for spam.`; - // Only attempt to send the message if message.author exists + // Only attempt to send the message if message.member exists if (message.member) { try { await SpamBanningService.#sendMessageToUser(message.author); @@ -46,8 +140,8 @@ class SpamBanningService { } await guild.members.ban(message.author.id, { reason: 'Account is compromised', + deleteMessageSeconds: deleteMessages ? 60 * 60 * 24 * 7 : 0, }); - message.react('✅'); return reply; } diff --git a/services/spam-ban/spam-banning.service.test.js b/services/spam-ban/spam-banning.service.test.js index 6c534b34..e0eabd27 100644 --- a/services/spam-ban/spam-banning.service.test.js +++ b/services/spam-ban/spam-banning.service.test.js @@ -13,10 +13,22 @@ afterAll(() => { function createInteractionMock(message, guild) { let replyArg; + let messageComponentReturn; return { + setMessageComponentReturn: (arg) => { + messageComponentReturn = arg; + }, reply: jest.fn((arg) => { replyArg = arg; + return { + awaitMessageComponent: jest.fn(() => { + if (messageComponentReturn === 'timeout') { + return Promise.reject(); + } + return Promise.resolve({ customId: messageComponentReturn }); + }), + }; }), guild, message, @@ -37,6 +49,8 @@ function createInteractionMock(message, guild) { guild.channels.cache .find((c) => c.id === config.channels.moderationLogChannelId) .getSendArg(), + + editReply: jest.fn(), }; } @@ -65,6 +79,7 @@ function createMessageMock() { }), getSendArg: () => sendArg, getReactArg: () => reactArg, + delete: jest.fn(), }; } @@ -105,7 +120,7 @@ function createGuildMock() { }; } -describe('Banning spammer in different channels', () => { +describe('Banning spammer in automod channel', () => { let interactionMock; beforeEach(() => { const messageMock = createMessageMock(); @@ -120,22 +135,61 @@ describe('Banning spammer in different channels', () => { expect(interactionMock.getBanArg()).toMatchSnapshot(); expect(interactionMock.getReplyArg()).toMatchSnapshot(); }); +}); - it('Does not ban user in different channels other than automod', async () => { - interactionMock.message.channelId = null; +describe('Banning spammer in other channels', () => { + let interactionMock; + beforeEach(() => { + const messageMock = createMessageMock(); + messageMock.channelId = '123'; + const guildMock = createGuildMock(); + interactionMock = createInteractionMock(messageMock, guildMock); + }); + + it('Asks for confirmation if not in automod channel', async () => { await SpamBanningService.handleInteraction(interactionMock); - expect(interactionMock.guild.members.ban).not.toHaveBeenCalled(); + expect(interactionMock.reply).toHaveBeenCalledTimes(1); expect(interactionMock.getReplyArg()).toMatchSnapshot(); + }); - interactionMock.message.channelId = config.channels.moderationLogChannelId; + it('Bans user and deletes their messages if delete message button clicked', async () => { + interactionMock.setMessageComponentReturn('deleteMessages'); await SpamBanningService.handleInteraction(interactionMock); - expect(interactionMock.guild.members.ban).not.toHaveBeenCalled(); + expect(interactionMock.guild.members.ban).toHaveBeenCalledTimes(1); + expect(interactionMock.getBanArg()).toMatchSnapshot(); expect(interactionMock.getReplyArg()).toMatchSnapshot(); + }); + + it('Bans user and does not delete their messages if keep message button clicked', async () => { + interactionMock.setMessageComponentReturn('dontDeleteMessages'); + await SpamBanningService.handleInteraction(interactionMock); + expect(interactionMock.guild.members.ban).toHaveBeenCalledTimes(1); + expect(interactionMock.getBanArg()).toMatchSnapshot(); + expect(interactionMock.getReplyArg()).toMatchSnapshot(); + }); - interactionMock.message.channelId = '21304782342'; + it('Still deletes the message that triggered the interaction', async () => { + interactionMock.setMessageComponentReturn('dontDeleteMessages'); await SpamBanningService.handleInteraction(interactionMock); + expect(interactionMock.message.delete).toHaveBeenCalledTimes(1); + }); + + it('Cancels the action if the cancel button is clicked', async () => { + interactionMock.setMessageComponentReturn('cancel'); + await SpamBanningService.handleInteraction(interactionMock); + expect(interactionMock.editReply).toHaveBeenCalledTimes(1); + expect(interactionMock.getReplyArg()).toMatchSnapshot(); + expect(interactionMock.message.delete).not.toHaveBeenCalled(); expect(interactionMock.guild.members.ban).not.toHaveBeenCalled(); + }); + + it('Cancels the action if the response times out', async () => { + interactionMock.setMessageComponentReturn('timeout'); + await SpamBanningService.handleInteraction(interactionMock); + expect(interactionMock.editReply).toHaveBeenCalledTimes(1); expect(interactionMock.getReplyArg()).toMatchSnapshot(); + expect(interactionMock.message.delete).not.toHaveBeenCalled(); + expect(interactionMock.guild.members.ban).not.toHaveBeenCalled(); }); }); From b9f4aa450a33a176abdbd69075dbfedd125d0962 Mon Sep 17 00:00:00 2001 From: Asartea <76259120+Asartea@users.noreply.github.com> Date: Fri, 21 Mar 2025 14:36:14 +0100 Subject: [PATCH 2/3] rename keep option --- .../spam-banning.service.test.js.snap | 20 +++++++++---------- services/spam-ban/spam-banning.service.js | 17 ++++++---------- .../spam-ban/spam-banning.service.test.js | 4 ++-- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/services/spam-ban/__snapshots__/spam-banning.service.test.js.snap b/services/spam-ban/__snapshots__/spam-banning.service.test.js.snap index 09a31486..55d695c9 100644 --- a/services/spam-ban/__snapshots__/spam-banning.service.test.js.snap +++ b/services/spam-ban/__snapshots__/spam-banning.service.test.js.snap @@ -90,7 +90,7 @@ exports[`Banning spammer in other channels Asks for confirmation if not in autom { "components": [ { - "custom_id": "dontDeleteMessages", + "custom_id": "keepMessages", "emoji": undefined, "label": "Don't delete messages", "style": 4, @@ -116,7 +116,7 @@ exports[`Banning spammer in other channels Asks for confirmation if not in autom ], "content": "Would you like to delete messages from the user being banned?", "ephemeral": true, - "withReponse": true, + "withResponse": true, } `; @@ -133,7 +133,7 @@ exports[`Banning spammer in other channels Bans user and deletes their messages { "components": [ { - "custom_id": "dontDeleteMessages", + "custom_id": "keepMessages", "emoji": undefined, "label": "Don't delete messages", "style": 4, @@ -159,7 +159,7 @@ exports[`Banning spammer in other channels Bans user and deletes their messages ], "content": "Would you like to delete messages from the user being banned?", "ephemeral": true, - "withReponse": true, + "withResponse": true, } `; @@ -176,7 +176,7 @@ exports[`Banning spammer in other channels Bans user and does not delete their m { "components": [ { - "custom_id": "dontDeleteMessages", + "custom_id": "keepMessages", "emoji": undefined, "label": "Don't delete messages", "style": 4, @@ -202,7 +202,7 @@ exports[`Banning spammer in other channels Bans user and does not delete their m ], "content": "Would you like to delete messages from the user being banned?", "ephemeral": true, - "withReponse": true, + "withResponse": true, } `; @@ -212,7 +212,7 @@ exports[`Banning spammer in other channels Cancels the action if the cancel butt { "components": [ { - "custom_id": "dontDeleteMessages", + "custom_id": "keepMessages", "emoji": undefined, "label": "Don't delete messages", "style": 4, @@ -238,7 +238,7 @@ exports[`Banning spammer in other channels Cancels the action if the cancel butt ], "content": "Would you like to delete messages from the user being banned?", "ephemeral": true, - "withReponse": true, + "withResponse": true, } `; @@ -248,7 +248,7 @@ exports[`Banning spammer in other channels Cancels the action if the response ti { "components": [ { - "custom_id": "dontDeleteMessages", + "custom_id": "keepMessages", "emoji": undefined, "label": "Don't delete messages", "style": 4, @@ -274,7 +274,7 @@ exports[`Banning spammer in other channels Cancels the action if the response ti ], "content": "Would you like to delete messages from the user being banned?", "ephemeral": true, - "withReponse": true, + "withResponse": true, } `; diff --git a/services/spam-ban/spam-banning.service.js b/services/spam-ban/spam-banning.service.js index ead88f7d..ccc7434e 100644 --- a/services/spam-ban/spam-banning.service.js +++ b/services/spam-ban/spam-banning.service.js @@ -81,8 +81,8 @@ class SpamBanningService { } static async #AskForDeleteMessages(interaction) { - const dontDeleteMessages = new ButtonBuilder() - .setCustomId('dontDeleteMessages') + const keepMessages = new ButtonBuilder() + .setCustomId('keepMessages') .setLabel("Don't delete messages") .setStyle(ButtonStyle.Danger); @@ -97,7 +97,7 @@ class SpamBanningService { .setStyle(ButtonStyle.Secondary); const row = new ActionRowBuilder().addComponents( - dontDeleteMessages, + keepMessages, deleteMessages, cancel, ); @@ -106,19 +106,14 @@ class SpamBanningService { content: 'Would you like to delete messages from the user being banned?', components: [row], ephemeral: true, - withReponse: true, + withResponse: true, }); try { const option = await response.awaitMessageComponent({ time: 60_000 }); - if (option.customId === 'deleteMessages') { - return { result: 'deleteMessages' }; - } - if (option.customId === 'dontDeleteMessages') { - return { result: 'dontDeleteMessages' }; - } - return { result: 'cancel' }; + return { result: option.customId }; + } catch { return { result: 'timeout' }; } diff --git a/services/spam-ban/spam-banning.service.test.js b/services/spam-ban/spam-banning.service.test.js index e0eabd27..81a5a481 100644 --- a/services/spam-ban/spam-banning.service.test.js +++ b/services/spam-ban/spam-banning.service.test.js @@ -161,7 +161,7 @@ describe('Banning spammer in other channels', () => { }); it('Bans user and does not delete their messages if keep message button clicked', async () => { - interactionMock.setMessageComponentReturn('dontDeleteMessages'); + interactionMock.setMessageComponentReturn('keepMessages'); await SpamBanningService.handleInteraction(interactionMock); expect(interactionMock.guild.members.ban).toHaveBeenCalledTimes(1); expect(interactionMock.getBanArg()).toMatchSnapshot(); @@ -169,7 +169,7 @@ describe('Banning spammer in other channels', () => { }); it('Still deletes the message that triggered the interaction', async () => { - interactionMock.setMessageComponentReturn('dontDeleteMessages'); + interactionMock.setMessageComponentReturn('keepMessages'); await SpamBanningService.handleInteraction(interactionMock); expect(interactionMock.message.delete).toHaveBeenCalledTimes(1); }); From 3b22f550ea19e92157d0116f83d3ab1a20fadead Mon Sep 17 00:00:00 2001 From: Asartea <76259120+Asartea@users.noreply.github.com> Date: Fri, 21 Mar 2025 14:44:51 +0100 Subject: [PATCH 3/3] appease the linter --- services/spam-ban/spam-banning.service.js | 1 - 1 file changed, 1 deletion(-) diff --git a/services/spam-ban/spam-banning.service.js b/services/spam-ban/spam-banning.service.js index ccc7434e..4305667d 100644 --- a/services/spam-ban/spam-banning.service.js +++ b/services/spam-ban/spam-banning.service.js @@ -113,7 +113,6 @@ class SpamBanningService { const option = await response.awaitMessageComponent({ time: 60_000 }); return { result: option.customId }; - } catch { return { result: 'timeout' }; }