-
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
2 changed files
with
323 additions
and
0 deletions.
There are no files selected for viewing
140 changes: 140 additions & 0 deletions
140
apps/barry/src/modules/moderation/commands/chatinput/purge/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,140 @@ | ||
import { | ||
type APIMessage, | ||
type APIUser, | ||
MessageFlags, | ||
PermissionFlagsBits | ||
} from "@discordjs/core"; | ||
import { | ||
type ApplicationCommandInteraction, | ||
SlashCommand, | ||
SlashCommandOptionBuilder, | ||
getCreatedAt | ||
} from "@barry/core"; | ||
import type ModerationModule from "../../../index.js"; | ||
|
||
import config from "../../../../../config.js"; | ||
|
||
/** | ||
* Options for the purge command. | ||
*/ | ||
export interface PurgeOptions { | ||
/** | ||
* The amount of messages to purge. | ||
*/ | ||
amount: number; | ||
|
||
/** | ||
* The user to purge messages from. | ||
*/ | ||
user?: APIUser; | ||
} | ||
|
||
/** | ||
* The maximum age of a message in milliseconds (14 days). | ||
*/ | ||
const MAX_MESSAGE_AGE = 1209600000; | ||
|
||
/** | ||
* Represents a slash command that purges messages from a channel. | ||
*/ | ||
export default class extends SlashCommand<ModerationModule> { | ||
/** | ||
* Represents a slash command that purges messages from a channel. | ||
* | ||
* @param module The module this command belongs to. | ||
*/ | ||
constructor(module: ModerationModule) { | ||
super(module, { | ||
name: "purge", | ||
description: "Purge messages from the current channel.", | ||
appPermissions: PermissionFlagsBits.ManageMessages, | ||
defaultMemberPermissions: PermissionFlagsBits.ManageMessages, | ||
guildOnly: true, | ||
options: { | ||
amount: SlashCommandOptionBuilder.integer({ | ||
description: "The amount of messages to purge.", | ||
minimum: 2, | ||
maximum: 100, | ||
required: true | ||
}), | ||
user: SlashCommandOptionBuilder.user({ | ||
description: "The user to purge messages from." | ||
}) | ||
} | ||
}); | ||
} | ||
|
||
/** | ||
* Purges messages from the current channel. | ||
* | ||
* @param interaction The interaction that triggered this command. | ||
* @param options The options for the command. | ||
*/ | ||
async execute(interaction: ApplicationCommandInteraction, options: PurgeOptions): Promise<void> { | ||
if (!interaction.isInvokedInGuild() || interaction.channel === undefined) { | ||
return; | ||
} | ||
|
||
const limit = options.user !== undefined | ||
? 100 | ||
: options.amount; | ||
|
||
const messages = await this.client.api.channels.getMessages(interaction.channel.id, { limit }); | ||
const messageIDs: string[] = []; | ||
for (const message of messages) { | ||
if (messageIDs.length >= options.amount) { | ||
break; | ||
} | ||
|
||
if (this.isPrunable(message, options.user?.id)) { | ||
messageIDs.push(message.id); | ||
} | ||
} | ||
|
||
if (messageIDs.length === 0) { | ||
return interaction.createMessage({ | ||
content: `${config.emotes.error} There are no messages to purge.`, | ||
flags: MessageFlags.Ephemeral | ||
}); | ||
} | ||
|
||
try { | ||
if (messageIDs.length === 1) { | ||
await this.client.api.channels.deleteMessage(interaction.channel.id, messageIDs[0]); | ||
} else { | ||
await this.client.api.channels.bulkDeleteMessages(interaction.channel.id, messageIDs); | ||
} | ||
} catch (error: unknown) { | ||
this.client.logger.error(error); | ||
|
||
return interaction.createMessage({ | ||
content: `${config.emotes.error} Failed to purge the messages.`, | ||
flags: MessageFlags.Ephemeral | ||
}); | ||
} | ||
|
||
await interaction.createMessage({ | ||
content: `${config.emotes.check} Successfully deleted ${messageIDs.length} messages.`, | ||
flags: MessageFlags.Ephemeral | ||
}); | ||
} | ||
|
||
/** | ||
* Checks whether a message is prunable. | ||
* | ||
* @param message The message to check. | ||
* @param userID Optionally, the only allowed author. | ||
* @returns Whether the message is prunable. | ||
*/ | ||
isPrunable(message: APIMessage, userID?: string): boolean { | ||
if (message.pinned) { | ||
return false; | ||
} | ||
|
||
if (getCreatedAt(message.id) < Date.now() - MAX_MESSAGE_AGE) { | ||
return false; | ||
} | ||
|
||
return userID === undefined || message.author.id === userID; | ||
} | ||
} |
183 changes: 183 additions & 0 deletions
183
apps/barry/tests/modules/moderation/commands/chatinput/purge/index.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,183 @@ | ||
import { type APIMessage, MessageFlags } from "@discordjs/core"; | ||
|
||
import { | ||
createMockApplicationCommandInteraction, | ||
mockMessage, | ||
mockUser | ||
} from "@barry/testing"; | ||
import { ApplicationCommandInteraction } from "@barry/core"; | ||
import { createMockApplication } from "../../../../../mocks/index.js"; | ||
|
||
import PurgeCommand, { type PurgeOptions } from "../../../../../../src/modules/moderation/commands/chatinput/purge/index.js"; | ||
import ModerationModule from "../../../../../../src/modules/moderation/index.js"; | ||
import * as core from "@barry/core"; | ||
|
||
describe("/purge", () => { | ||
let command: PurgeCommand; | ||
let interaction: ApplicationCommandInteraction; | ||
let options: PurgeOptions; | ||
|
||
let messageIDs: string[]; | ||
let messages: APIMessage[]; | ||
|
||
beforeEach(() => { | ||
const client = createMockApplication(); | ||
const module = new ModerationModule(client); | ||
command = new PurgeCommand(module); | ||
|
||
const data = createMockApplicationCommandInteraction(); | ||
interaction = new ApplicationCommandInteraction(data, client, vi.fn()); | ||
|
||
options = { | ||
amount: 10 | ||
}; | ||
|
||
messageIDs = [ | ||
"30527482987641765", | ||
"30527482987641766", | ||
"30527482987641767", | ||
"30527482987641768", | ||
"30527482987641769", | ||
"30527482987641770", | ||
"30527482987641771", | ||
"30527482987641772", | ||
"30527482987641773", | ||
"30527482987641774" | ||
]; | ||
messages = messageIDs.map((id) => ({ | ||
...mockMessage, id | ||
})); | ||
|
||
client.api.channels.bulkDeleteMessages = vi.fn(); | ||
client.api.channels.deleteMessage = vi.fn(); | ||
vi.spyOn(client.api.channels, "getMessages").mockResolvedValue(messages); | ||
vi.spyOn(core, "getCreatedAt").mockReturnValue(Date.now()); | ||
}); | ||
|
||
describe("execute", () => { | ||
it("should purge messages from the current channel", async () => { | ||
const deleteSpy = vi.spyOn(command.client.api.channels, "bulkDeleteMessages"); | ||
|
||
await command.execute(interaction, options); | ||
|
||
expect(deleteSpy).toHaveBeenCalledOnce(); | ||
expect(deleteSpy).toHaveBeenCalledWith(interaction.channel?.id, expect.any(Array)); | ||
}); | ||
|
||
it("should purge messages from the current channel from a user", async () => { | ||
const deleteSpy = vi.spyOn(command.client.api.channels, "bulkDeleteMessages"); | ||
messages.splice(3); | ||
options.user = mockUser; | ||
|
||
await command.execute(interaction, options); | ||
|
||
expect(deleteSpy).toHaveBeenCalledOnce(); | ||
expect(deleteSpy).toHaveBeenCalledWith(interaction.channel?.id, messageIDs.slice(0, 3)); | ||
}); | ||
|
||
it("should only delete one message if the amount is '1'", async () => { | ||
const deleteSpy = vi.spyOn(command.client.api.channels, "deleteMessage"); | ||
options.amount = 1; | ||
|
||
await command.execute(interaction, options); | ||
|
||
expect(deleteSpy).toHaveBeenCalledOnce(); | ||
expect(deleteSpy).toHaveBeenCalledWith(interaction.channel?.id, messageIDs[0]); | ||
}); | ||
|
||
it("should show an error message if no messages are found", async () => { | ||
vi.spyOn(command.client.api.channels, "getMessages").mockResolvedValue([]); | ||
const createSpy = vi.spyOn(interaction, "createMessage"); | ||
|
||
await command.execute(interaction, options); | ||
|
||
expect(createSpy).toHaveBeenCalledOnce(); | ||
expect(createSpy).toHaveBeenCalledWith({ | ||
content: expect.stringContaining("There are no messages to purge."), | ||
flags: MessageFlags.Ephemeral | ||
}); | ||
}); | ||
|
||
it("should show an error message if an error occurs", async () => { | ||
const error = new Error("Oh no!"); | ||
const createSpy = vi.spyOn(interaction, "createMessage"); | ||
vi.spyOn(command.client.api.channels, "bulkDeleteMessages").mockRejectedValue(error); | ||
|
||
await command.execute(interaction, options); | ||
|
||
expect(createSpy).toHaveBeenCalledOnce(); | ||
expect(createSpy).toHaveBeenCalledWith({ | ||
content: expect.stringContaining("Failed to purge the messages."), | ||
flags: MessageFlags.Ephemeral | ||
}); | ||
}); | ||
|
||
it("should log the error if an error occurs", async () => { | ||
const error = new Error("Oh no!"); | ||
vi.spyOn(command.client.api.channels, "bulkDeleteMessages").mockRejectedValue(error); | ||
|
||
await command.execute(interaction, options); | ||
|
||
expect(command.client.logger.error).toHaveBeenCalledOnce(); | ||
expect(command.client.logger.error).toHaveBeenCalledWith(error); | ||
}); | ||
|
||
it("should show a success message if the messages are purged", async () => { | ||
const createSpy = vi.spyOn(interaction, "createMessage"); | ||
|
||
await command.execute(interaction, options); | ||
|
||
expect(createSpy).toHaveBeenCalledOnce(); | ||
expect(createSpy).toHaveBeenCalledWith({ | ||
content: expect.stringContaining("Successfully deleted 10 messages"), | ||
flags: MessageFlags.Ephemeral | ||
}); | ||
}); | ||
|
||
it("should ignore if the interaction was invoked outside a guild", async () => { | ||
delete interaction.guildID; | ||
|
||
await command.execute(interaction, options); | ||
|
||
expect(interaction.acknowledged).toBe(false); | ||
}); | ||
|
||
it("should ignore if the interaction was invoked outside a channel", async () => { | ||
delete interaction.channel; | ||
|
||
await command.execute(interaction, options); | ||
|
||
expect(interaction.acknowledged).toBe(false); | ||
}); | ||
}); | ||
|
||
describe("isPrunable", () => { | ||
it("should return false if the message is pinned", () => { | ||
const message = { ...mockMessage, pinned: true }; | ||
|
||
const result = command.isPrunable(message); | ||
|
||
expect(result).toBe(false); | ||
}); | ||
|
||
it("should return false if the message is older than 14 days", () => { | ||
vi.spyOn(core, "getCreatedAt").mockReturnValue(Date.now() - 1382400000); | ||
|
||
const result = command.isPrunable(mockMessage); | ||
|
||
expect(result).toBe(false); | ||
}); | ||
|
||
it("should return false if the message is not from the requested user", () => { | ||
const result = command.isPrunable(mockMessage, "257522665458237440"); | ||
|
||
expect(result).toBe(false); | ||
}); | ||
|
||
it("should return true if the message meets all requirements", () => { | ||
const result = command.isPrunable(mockMessage); | ||
|
||
expect(result).toBe(true); | ||
}); | ||
}); | ||
}); |