Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement purge command #80

Merged
merged 1 commit into from
Sep 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions apps/barry/src/modules/moderation/commands/chatinput/purge/index.ts
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;
}
}
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);
});
});
});