diff --git a/src/commands/RuleCommand.ts b/src/commands/RuleCommand.ts index 11f3c3f..ff57cd3 100644 --- a/src/commands/RuleCommand.ts +++ b/src/commands/RuleCommand.ts @@ -1,4 +1,4 @@ -import {ColorResolvable, CommandInteraction, EmbedBuilder, ApplicationCommandOptionType} from "discord.js"; +import {ColorResolvable, CommandInteraction, EmbedBuilder, ApplicationCommandOptionType, User} from "discord.js"; import {Discord, Slash, SlashChoice, SlashOption} from "discordx"; import getConfigValue from "../utils/getConfigValue"; import GenericObject from "../interfaces/GenericObject"; @@ -13,11 +13,19 @@ const rules = getConfigValue("rules").map(it => ({name: it.name, value: @Discord() class RuleCommand { + private lastPinged = new Map(); + private lastPingPerPinger = new Map(); + @Slash({ name: "rule", description: "Get info about a rule" }) async onInteract( @SlashChoice(...rules) @SlashOption({ name: "rule", description: "Name of a rule", type: ApplicationCommandOptionType.String, required: true }) ruleName: string, - interaction: CommandInteraction - ): Promise { + @SlashOption({ name: "user", description: "User to ping", type: ApplicationCommandOptionType.User, required: false}) mentionedUser: User | undefined, + interaction: CommandInteraction): Promise { + if (interaction === undefined) { + console.error("Interaction is undefined in RuleCommand"); + return; + } + const embed = new EmbedBuilder(); const rule = getConfigValue("rules").find(rule => rule.triggers.includes(ruleName)); @@ -30,8 +38,56 @@ class RuleCommand { embed.addFields([{ name: "To familiarise yourself with all of the server's rules please see", value: "<#240884566519185408>" }]); embed.setColor(getConfigValue>("EMBED_COLOURS").SUCCESS); } - await interaction.reply({embeds: [embed]}); + await interaction.reply({ embeds: [embed] }); + + if (mentionedUser) { + const userId = mentionedUser.id; + const now = Date.now(); + const cooldownValue = getConfigValue("RULE_PING_COOLDOWN_PINGED"); + + const lastTime = this.lastPinged.get(userId); + + if (lastTime) { + const secondsPassed = (now - lastTime) / 1000; + + if (secondsPassed < cooldownValue) { + const remaining = (cooldownValue - secondsPassed).toFixed(0); + + await interaction.followUp({ + content: `Slow down! That user can be pinged again in ${remaining}s.`, + ephemeral: true + }); + return; + } + } + + // If they passed the check, update the map with the new time + this.lastPinged.set(userId, now); + + const cooldownValuePinger = getConfigValue("RULE_PING_COOLDOWN_PINGER"); + + const lastTimePinger = this.lastPingPerPinger.get(interaction.user.id); + + if (lastTimePinger) { + const secondsPassed = (now - lastTimePinger) / 1000; + + if (secondsPassed < cooldownValuePinger) { + const remaining = (cooldownValuePinger - secondsPassed).toFixed(0); + + await interaction.followUp({ + content: `Slow down! You can ping someone again in ${remaining}s.`, + ephemeral: true + }); + return; + } + } + + // If they passed the check, update the map with the new time + this.lastPingPerPinger.set(interaction.user.id, now); + + await interaction.followUp({ content: `<@${mentionedUser?.id}> Please read the rule mentioned above, and take a moment to familiarise yourself with the rules.` }); + } } } -export default RuleCommand; \ No newline at end of file +export default RuleCommand; diff --git a/src/config.json b/src/config.json index 2c52ee0..cd3fc7e 100644 --- a/src/config.json +++ b/src/config.json @@ -105,5 +105,7 @@ "triggers": ["9", "sfw", "clean", "appropriate"], "description": "Keep it appropriate, some people use this at school or at work." } - ] + ], + "RULE_PING_COOLDOWN_PINGED": 30, + "RULE_PING_COOLDOWN_PINGER": 180 } diff --git a/src/event/handlers/AntiSpamHandler.ts b/src/event/handlers/AntiSpamHandler.ts index df0f5e3..8c0d807 100644 --- a/src/event/handlers/AntiSpamHandler.ts +++ b/src/event/handlers/AntiSpamHandler.ts @@ -21,6 +21,49 @@ class AntiSpamHandler extends EventHandler { const member = message.member; + let antiSpamChannel = await message.guild.channels.fetch(antiSpamChannelId) as TextChannel; + + if (!antiSpamChannel) { // Retry loop + let tries = 0; + let success = 0; + + while (tries <= 3) { + antiSpamChannel = await message.guild.channels.fetch(antiSpamChannelId) as TextChannel; + if (antiSpamChannel) { + success = 1; + break; + } + tries += 1; + } + if (!success) { + logger.error("Failed to increment counter"); + try { + await member.ban({deleteMessageSeconds: 60 * 60, reason: "Member posted in anti-spam channel"}); + + const logsChannel = await message.guild.channels.fetch(logsChannelId) as TextChannel; + + await logsChannel.send(`User ${member.user.username} (\`${member.id}\`) was banned for posting in <#${antiSpamChannelId}>. Their recent messages have been deleted.`); + } catch (error) { + logger.error("AntiSpamHandler error", {error}); + } + return; + } + } + const antiSpamMessages = await antiSpamChannel.messages.fetch({ limit: 100 }); + // Check if any of those messages were sent by THIS bot + const hasSentMessageInAntiSpamChannel = antiSpamMessages.some((msg: Message) => msg.author.id === message.client.user?.id && msg.embeds.length === 0); + + if (hasSentMessageInAntiSpamChannel && antiSpamMessages.find((msg: Message) => msg.author.id === message.client.user?.id && msg.embeds.length === 0)) { + const botMessage = antiSpamMessages.find((msg: Message) => msg.author.id === message.client.user?.id && msg.embeds.length === 0); + const botMessageContent = botMessage!.content; + const counterValueStr = botMessageContent.split(" ")[0].replace(/\D/g, ""); + const counterValueInt = parseInt(counterValueStr, 10); + + botMessage!.edit(`**${counterValueInt + 1}** spam accounts banned.`); + } else { + antiSpamChannel.send("**1** spam account has been banned."); + } + try { await member.ban({deleteMessageSeconds: 60 * 60, reason: "Member posted in anti-spam channel"}); diff --git a/test/appTest.ts b/test/appTest.ts index e8d007d..db48d0e 100644 --- a/test/appTest.ts +++ b/test/appTest.ts @@ -21,7 +21,9 @@ describe("App", () => { sandbox = createSandbox(); // @ts-ignore - (axios as unknown as AxiosCacheInstance).defaults.cache = undefined; + const axiosCache = axios as unknown as AxiosCacheInstance; + + axiosCache.defaults.cache = undefined; loginStub = sandbox.stub(Client.prototype, "login"); getStub = sandbox.stub(axios, "get").resolves(); diff --git a/test/commands/RuleCommandTest.ts b/test/commands/RuleCommandTest.ts index 7cb4902..acf6e97 100644 --- a/test/commands/RuleCommandTest.ts +++ b/test/commands/RuleCommandTest.ts @@ -5,6 +5,7 @@ import { BaseMocks } from "@lambocreeper/mock-discord.js"; import RuleCommand from "../../src/commands/RuleCommand"; import { EMBED_COLOURS } from "../../src/config.json"; import NumberUtils from "../../src/utils/NumberUtils"; +import { User } from "discord.js"; describe("RuleCommand", () => { describe("run()", () => { @@ -12,25 +13,64 @@ describe("RuleCommand", () => { let command: RuleCommand; let replyStub: sinon.SinonStub; let interaction: any; + let interaction2: any; + let userMock: User; + let userMock2: User; + let followUpStub: sinon.SinonStub; + let clock: sinon.SinonFakeTimers; beforeEach(() => { sandbox = createSandbox(); command = new RuleCommand(); replyStub = sandbox.stub().resolves(); + followUpStub = sandbox.stub().resolves(); interaction = { reply: replyStub, + followUp: followUpStub, user: BaseMocks.getGuildMember() }; + interaction2 = { + reply: replyStub, + followUp: followUpStub, + user: { + ...BaseMocks.getGuildMember(), + id: `${BaseMocks.getGuildMember().id}-2` + } + }; + userMock = { + id: "123456789012345678", + username: "TestUser", + discriminator: "0000", + tag: "TestUser#0", + bot: false, + toString: () => "<@123456789012345678>" + } as unknown as User; + userMock2 = { + id: "123456789012345679", + username: "TestUser", + discriminator: "0000", + tag: "TestUser#0", + bot: false, + toString: () => "<@123456789012345679>" + } as unknown as User; + + const baseTime = 10 * 864_000_000; + const jitter = Math.floor(Math.random() * (2 * 864_000_000)) - 864_000_000; // Up to +-10d + + clock = sandbox.useFakeTimers({ + now: baseTime + jitter, + shouldAdvanceTime: false + }); }); it("sends a message to the channel", async () => { - await command.onInteract("0", interaction); + await command.onInteract("0", undefined, interaction); expect(replyStub.calledOnce).to.be.true; }); it("states rule 0 if you ask for rule 0", async () => { - await command.onInteract("0", interaction); + await command.onInteract("0", undefined, interaction); const embed = replyStub.getCall(0).firstArg.embeds[0]; @@ -43,7 +83,7 @@ describe("RuleCommand", () => { }); it("states rule 1 if you ask for rule 1", async () => { - await command.onInteract("1", interaction); + await command.onInteract("1", undefined, interaction); const embed = replyStub.getCall(0).firstArg.embeds[0]; @@ -56,7 +96,7 @@ describe("RuleCommand", () => { }); it("states rule 2 if you ask for rule 2", async () => { - await command.onInteract("2", interaction); + await command.onInteract("2", undefined, interaction); const embed = replyStub.getCall(0).firstArg.embeds[0]; @@ -69,7 +109,7 @@ describe("RuleCommand", () => { }); it("states rule 3 if you ask for rule 3", async () => { - await command.onInteract("3", interaction); + await command.onInteract("3", undefined, interaction); const embed = replyStub.getCall(0).firstArg.embeds[0]; @@ -82,7 +122,7 @@ describe("RuleCommand", () => { }); it("states rule 4 if you ask for rule 4", async () => { - await command.onInteract("4", interaction); + await command.onInteract("4", undefined, interaction); const embed = replyStub.getCall(0).firstArg.embeds[0]; @@ -95,7 +135,7 @@ describe("RuleCommand", () => { }); it("states rule 5 if you ask for rule 5", async () => { - await command.onInteract("5", interaction); + await command.onInteract("5", undefined, interaction); const embed = replyStub.getCall(0).firstArg.embeds[0]; @@ -108,7 +148,7 @@ describe("RuleCommand", () => { }); it("states rule 6 if you ask for rule 6", async () => { - await command.onInteract("6", interaction); + await command.onInteract("6", undefined, interaction); const embed = replyStub.getCall(0).firstArg.embeds[0]; @@ -121,7 +161,7 @@ describe("RuleCommand", () => { }); it("states rule 7 if you ask for rule 7", async () => { - await command.onInteract("7", interaction); + await command.onInteract("7", undefined, interaction); const embed = replyStub.getCall(0).firstArg.embeds[0]; @@ -134,7 +174,7 @@ describe("RuleCommand", () => { }); it("states rule 8 if you ask for rule 8", async () => { - await command.onInteract("8", interaction); + await command.onInteract("8", undefined, interaction); const embed = replyStub.getCall(0).firstArg.embeds[0]; @@ -147,7 +187,7 @@ describe("RuleCommand", () => { }); it("states rule 9 if you ask for rule 9", async () => { - await command.onInteract("9", interaction); + await command.onInteract("9", undefined, interaction); const embed = replyStub.getCall(0).firstArg.embeds[0]; @@ -159,7 +199,92 @@ describe("RuleCommand", () => { expect(embed.data.fields[0].value).to.equal("<#240884566519185408>"); }); + it("success ping", async () => { + await command.onInteract("0", userMock, interaction); + + expect(followUpStub.calledOnce).to.be.true; + + const arg = followUpStub.firstCall.firstArg; + + expect(arg.content).to.equal( + `<@${userMock.id}> Please read the rule mentioned above, and take a moment to familiarise yourself with the rules.` + ); + expect(arg.ephemeral).to.be.undefined; + }); + + [1, 2, 3].forEach(() => { + it("correct ping cooldown for pinged user", async () => { + await command.onInteract("0", userMock, interaction); + + clock.tick(1_000); + + await command.onInteract("0", userMock, interaction2); + expect(followUpStub.calledTwice).to.be.true; + const arg = followUpStub.secondCall.firstArg; + + expect(arg.ephemeral).to.be.true; + expect(arg.content).to.include("That user can be pinged again"); + }); + }); + + [1, 2, 3].forEach(() => { + it("correct ping cooldown for pinger", async () => { + await command.onInteract("0", userMock, interaction); + + clock.tick(1_000); + + await command.onInteract("0", userMock2, interaction); + + expect(followUpStub.calledTwice).to.be.true; + + const arg = followUpStub.secondCall.firstArg; + + expect(arg.ephemeral).to.be.true; + expect(arg.content).to.include("You can ping someone again"); + }); + }); + + [-5, -4, -3, -2, -1].forEach(rule => { + it(`correct failure for invalid rule number ${rule}`, async () => { + await command.onInteract(rule.toString(), undefined, interaction); + + expect(replyStub.calledOnce).to.be.true; + + const embed = replyStub.firstCall.firstArg.embeds[0]; + + expect(embed.data.title).to.be.undefined; + expect(embed.data.description).to.be.undefined; + expect(embed.data.fields).to.be.undefined; + }); + }); + + [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 101029310239].forEach(rule => { + it(`correct failure for invalid rule number ${rule}`, async () => { + await command.onInteract(rule.toString(), undefined, interaction); + + expect(replyStub.calledOnce).to.be.true; + + const embed = replyStub.firstCall.firstArg.embeds[0]; + + expect(embed.data.title).to.be.undefined; + expect(embed.data.description).to.be.undefined; + expect(embed.data.fields).to.be.undefined; + }); + }); + + it("correct failure for interaction === undefined", async () => { + const errorStub = sandbox.stub(console, "error"); + + await command.onInteract("0", undefined, undefined as any); + + expect(errorStub.calledOnce).to.be.true; + expect(errorStub.firstCall.args[0]).to.include("Interaction is undefined"); + + errorStub.restore(); + }); + afterEach(() => { + clock.restore(); sandbox.restore(); }); }); diff --git a/test/event/handlers/AntiSpamHandlerTest.ts b/test/event/handlers/AntiSpamHandlerTest.ts index 6f1f75f..f9a574c 100644 --- a/test/event/handlers/AntiSpamHandlerTest.ts +++ b/test/event/handlers/AntiSpamHandlerTest.ts @@ -25,6 +25,62 @@ describe("AntiSpamHandler", () => { const ANTISPAM_CHANNEL_ID = "antispam"; const LOG_CHANNEL_ID = "logs"; + function createBotCounterMessage(count: number, overrides: Partial = {}) { + return { + id: "bot-message-id", + content: `**${count}** spam accounts banned.`, + author: { id: "bot-id" }, + embeds: [], + edit: sandbox.stub().resolves(), + ...overrides + }; + } + function setupAntiSpamChannelWithMessages(messages: any[]) { + const fakeAntiSpamChannel = { + id: ANTISPAM_CHANNEL_ID, + messages: { + fetch: sandbox.stub().resolves({ + some: (fn: any) => messages.some(fn), + find: (fn: any) => messages.find(fn) + }) + }, + send: sandbox.stub().resolves() + }; + + messageMock.guild.channels.fetch = sandbox.stub().callsFake(async (id: string) => { + if (id === ANTISPAM_CHANNEL_ID) return fakeAntiSpamChannel; + if (id === LOG_CHANNEL_ID) return logsChannelMock; + return null; + }); + + return fakeAntiSpamChannel; + } + function setupFetchWithRetries(failuresBeforeSuccess: number | null, fakeAntiSpamChannel: any) { + let callCount = 0; + + messageMock.guild.channels.fetch = sandbox.stub().callsFake(async (id: string) => { + if (id === LOG_CHANNEL_ID) { + return logsChannelMock; + } + + if (id === ANTISPAM_CHANNEL_ID) { + if (failuresBeforeSuccess === null) { + // Always fail + return null; + } + + if (callCount < failuresBeforeSuccess) { + callCount++; + return null; + } + + return fakeAntiSpamChannel; + } + + return null; + }); + } + beforeEach(() => { sandbox = createSandbox(); handler = new AntiSpamHandler(); @@ -33,27 +89,59 @@ describe("AntiSpamHandler", () => { getConfigValueStub.withArgs("LOG_CHANNEL_ID").returns(LOG_CHANNEL_ID); memberMock = BaseMocks.getGuildMember(); + sandbox.stub(memberMock, "ban").resolves(); logsChannelMock = BaseMocks.getTextChannel(); sandbox.stub(logsChannelMock, "send").resolves(); + const mockBotMessage = { + id: "12345", + content: "**0** spam accounts banned.", + author: { id: "bot-id" }, + edit: sandbox.stub().resolves() + }; + + // Update your messageMock + const fakeAntiSpamChannel = { + id: ANTISPAM_CHANNEL_ID, + messages: { + fetch: sandbox.stub().resolves({ + // Mocking the collection returned by fetch + some: () => true, + find: () => mockBotMessage + }) + }, + send: sandbox.stub().resolves(mockBotMessage) + }; + + logsChannelMock = { + send: sandbox.stub().resolves() + }; + + // 4. The Main Message Mock messageMock = { - channel: CustomMocks.getGuildChannel({id: ANTISPAM_CHANNEL_ID}), + client: { user: { id: "bot-id" } }, + author: { bot: false, username: "testuser" }, + member: { + id: "user-id", + user: { username: "testuser" }, + ban: sandbox.stub().resolves() + }, + channel: { id: ANTISPAM_CHANNEL_ID }, guild: { channels: { - fetch: sandbox.stub().resolves(logsChannelMock) + // This handles your retry loop and the logs fetch + fetch: sandbox.stub().callsFake(async id => { + if (id === ANTISPAM_CHANNEL_ID) return fakeAntiSpamChannel; + if (id === LOG_CHANNEL_ID) return logsChannelMock; + return null; + }) } - }, - member: memberMock, - author: CustomMocks.getUser({bot: false}) + } }; }); - afterEach(() => { - sandbox.restore(); - }); - it("bans user with deleteMessageSeconds and logs when message is in anti-spam channel", async () => { await handler.handle(messageMock); expect(messageMock.member.ban.calledOnce).to.be.true; @@ -89,5 +177,110 @@ describe("AntiSpamHandler", () => { expect(memberMock.ban.called).to.be.false; expect(logsChannelMock.send.called).to.be.false; }); + + [ + [0, 1], + [4, 5], + [9, 10], + [10, 11], + [99, 100], + [100, 101], + [192831923, 192831924] + ].forEach(([from, to]) => { + it(`counter already existing, modified correctly (${from} -> ${to})`, async () => { + const botMessage = createBotCounterMessage(from); + + setupAntiSpamChannelWithMessages([botMessage]); + + await handler.handle(messageMock); + + expect(botMessage.edit.calledOnce).to.be.true; + expect(botMessage.edit.firstCall.args[0]).to.equal(`**${to}** spam accounts banned.`); + }); + }); + + it("counter not already existing, created correctly (counter = 1)", async () => { + const fakeChannel = setupAntiSpamChannelWithMessages([]); + + await handler.handle(messageMock); + + expect(fakeChannel.send.calledOnce).to.be.true; + expect(fakeChannel.send.firstCall.args[0]).to.equal("**1** spam account has been banned."); + }); + + it("counter does not modify a previous message which contains embeds", async () => { + const botMessageWithEmbed = createBotCounterMessage(5, { + embeds: [{}] + }); + + setupAntiSpamChannelWithMessages([botMessageWithEmbed]); + + await handler.handle(messageMock); + + expect(botMessageWithEmbed.edit.called).to.be.false; + }); + + it("counter does not modify a previous message sent by another user", async () => { + const userMessage = createBotCounterMessage(5, { + author: { id: "some-user-id" } + }); + + setupAntiSpamChannelWithMessages([userMessage]); + + await handler.handle(messageMock); + + expect(userMessage.edit.called).to.be.false; + }); + + it("counter does not modify a previous message sent by another bot", async () => { + const otherBotMessage = createBotCounterMessage(5, { + author: { id: "another-bot-id" } + }); + + setupAntiSpamChannelWithMessages([otherBotMessage]); + + await handler.handle(messageMock); + + expect(otherBotMessage.edit.called).to.be.false; + }); + + [ + { failures: 1, shouldSucceed: true }, + { failures: 2, shouldSucceed: true }, + { failures: 3, shouldSucceed: true }, + { failures: 4, shouldSucceed: true }, + { failures: 5, shouldSucceed: false} + ].forEach(({ failures, shouldSucceed }) => { + it(`retry logic: fetch fails ${failures} time(s) → ${shouldSucceed ? "succeeds" : "gives up"}`, async () => { + const botMessage = createBotCounterMessage(0); + const fakeChannel = setupAntiSpamChannelWithMessages([botMessage]); + + let callCount = 0; + + messageMock.guild.channels.fetch = sandbox.stub().callsFake(async (id: string) => { + if (id === LOG_CHANNEL_ID) { + return logsChannelMock; + } + + if (id === ANTISPAM_CHANNEL_ID) { + callCount++; + return callCount <= failures ? null : fakeChannel; + } + + return null; + }); + + await handler.handle(messageMock); + + // Inline conditionals for assertions + expect(botMessage.edit.called).to.equal(shouldSucceed); + expect(messageMock.member.ban.calledOnce).to.be.true; + expect(logsChannelMock.send.called).to.equal(true); + }); + }); + + afterEach(() => { + sandbox.restore(); + }); }); });