feat: implement message pruning command with dedicated service and UI components.
This commit is contained in:
179
src/commands/admin/prune.ts
Normal file
179
src/commands/admin/prune.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { createCommand } from "@/lib/utils";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||||
|
import { config } from "@/lib/config";
|
||||||
|
import { PruneService } from "@/modules/moderation/prune.service";
|
||||||
|
import {
|
||||||
|
getConfirmationMessage,
|
||||||
|
getProgressEmbed,
|
||||||
|
getSuccessEmbed,
|
||||||
|
getPruneErrorEmbed,
|
||||||
|
getPruneWarningEmbed,
|
||||||
|
getCancelledEmbed
|
||||||
|
} from "@/modules/moderation/prune.view";
|
||||||
|
|
||||||
|
export const prune = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("prune")
|
||||||
|
.setDescription("Delete messages in bulk (admin only)")
|
||||||
|
.addIntegerOption(option =>
|
||||||
|
option
|
||||||
|
.setName("amount")
|
||||||
|
.setDescription(`Number of messages to delete (1-${config.moderation?.prune?.maxAmount || 100})`)
|
||||||
|
.setRequired(false)
|
||||||
|
.setMinValue(1)
|
||||||
|
.setMaxValue(config.moderation?.prune?.maxAmount || 100)
|
||||||
|
)
|
||||||
|
.addUserOption(option =>
|
||||||
|
option
|
||||||
|
.setName("user")
|
||||||
|
.setDescription("Only delete messages from this user")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
.addBooleanOption(option =>
|
||||||
|
option
|
||||||
|
.setName("all")
|
||||||
|
.setDescription("Delete all messages in the channel")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const amount = interaction.options.getInteger("amount");
|
||||||
|
const user = interaction.options.getUser("user");
|
||||||
|
const all = interaction.options.getBoolean("all") || false;
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if (!amount && !all) {
|
||||||
|
// Default to 10 messages
|
||||||
|
} else if (amount && all) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalAmount = all ? 'all' : (amount || 10);
|
||||||
|
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
||||||
|
|
||||||
|
// Check if confirmation is needed
|
||||||
|
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
||||||
|
|
||||||
|
if (needsConfirmation) {
|
||||||
|
// Estimate message count for confirmation
|
||||||
|
let estimatedCount: number | undefined;
|
||||||
|
if (all) {
|
||||||
|
try {
|
||||||
|
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
||||||
|
} catch {
|
||||||
|
estimatedCount = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
|
||||||
|
const response = await interaction.editReply({ embeds, components });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const confirmation = await response.awaitMessageComponent({
|
||||||
|
filter: (i) => i.user.id === interaction.user.id,
|
||||||
|
componentType: ComponentType.Button,
|
||||||
|
time: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmation.customId === "cancel_prune") {
|
||||||
|
await confirmation.update({
|
||||||
|
embeds: [getCancelledEmbed()],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User confirmed, proceed with deletion
|
||||||
|
await confirmation.update({
|
||||||
|
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute deletion with progress callback for 'all' mode
|
||||||
|
const result = await PruneService.deleteMessages(
|
||||||
|
interaction.channel!,
|
||||||
|
{
|
||||||
|
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||||
|
userId: user?.id,
|
||||||
|
all
|
||||||
|
},
|
||||||
|
all ? async (progress) => {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getProgressEmbed(progress)]
|
||||||
|
});
|
||||||
|
} : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show success
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getSuccessEmbed(result)],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes("time")) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No confirmation needed, proceed directly
|
||||||
|
const result = await PruneService.deleteMessages(
|
||||||
|
interaction.channel!,
|
||||||
|
{
|
||||||
|
amount: finalAmount as number,
|
||||||
|
userId: user?.id,
|
||||||
|
all: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if no messages were found
|
||||||
|
if (result.deletedCount === 0) {
|
||||||
|
if (user) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getSuccessEmbed(result)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Prune command error:", error);
|
||||||
|
|
||||||
|
let errorMessage = "An unexpected error occurred while trying to delete messages.";
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message.includes("permission")) {
|
||||||
|
errorMessage = "I don't have permission to delete messages in this channel.";
|
||||||
|
} else if (error.message.includes("channel type")) {
|
||||||
|
errorMessage = "This command cannot be used in this type of channel.";
|
||||||
|
} else {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneErrorEmbed(errorMessage)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -56,6 +56,14 @@ export interface GameConfigType {
|
|||||||
channelId: string;
|
channelId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
};
|
};
|
||||||
|
moderation: {
|
||||||
|
prune: {
|
||||||
|
maxAmount: number;
|
||||||
|
confirmThreshold: number;
|
||||||
|
batchSize: number;
|
||||||
|
batchDelayMs: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial default config state
|
// Initial default config state
|
||||||
@@ -124,7 +132,22 @@ const configSchema = z.object({
|
|||||||
terminal: z.object({
|
terminal: z.object({
|
||||||
channelId: z.string(),
|
channelId: z.string(),
|
||||||
messageId: z.string()
|
messageId: z.string()
|
||||||
}).optional()
|
}).optional(),
|
||||||
|
moderation: z.object({
|
||||||
|
prune: z.object({
|
||||||
|
maxAmount: z.number().default(100),
|
||||||
|
confirmThreshold: z.number().default(50),
|
||||||
|
batchSize: z.number().default(100),
|
||||||
|
batchDelayMs: z.number().default(1000)
|
||||||
|
})
|
||||||
|
}).default({
|
||||||
|
prune: {
|
||||||
|
maxAmount: 100,
|
||||||
|
confirmThreshold: 50,
|
||||||
|
batchSize: 100,
|
||||||
|
batchDelayMs: 1000
|
||||||
|
}
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export function reloadConfig() {
|
export function reloadConfig() {
|
||||||
|
|||||||
198
src/modules/moderation/prune.service.ts
Normal file
198
src/modules/moderation/prune.service.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { Collection, Message, PermissionFlagsBits } from "discord.js";
|
||||||
|
import type { TextBasedChannel } from "discord.js";
|
||||||
|
import type { PruneOptions, PruneResult, PruneProgress } from "./prune.types";
|
||||||
|
import { config } from "@/lib/config";
|
||||||
|
|
||||||
|
export class PruneService {
|
||||||
|
/**
|
||||||
|
* Delete messages from a channel based on provided options
|
||||||
|
*/
|
||||||
|
static async deleteMessages(
|
||||||
|
channel: TextBasedChannel,
|
||||||
|
options: PruneOptions,
|
||||||
|
progressCallback?: (progress: PruneProgress) => Promise<void>
|
||||||
|
): Promise<PruneResult> {
|
||||||
|
// Validate channel permissions
|
||||||
|
if (!('permissionsFor' in channel)) {
|
||||||
|
throw new Error("Cannot check permissions for this channel type");
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = channel.permissionsFor(channel.client.user!);
|
||||||
|
if (!permissions?.has(PermissionFlagsBits.ManageMessages)) {
|
||||||
|
throw new Error("Missing permission to manage messages in this channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { amount, userId, all } = options;
|
||||||
|
const batchSize = config.moderation.prune.batchSize;
|
||||||
|
const batchDelay = config.moderation.prune.batchDelayMs;
|
||||||
|
|
||||||
|
let totalDeleted = 0;
|
||||||
|
let totalSkipped = 0;
|
||||||
|
let requestedCount = amount || 10;
|
||||||
|
let lastMessageId: string | undefined;
|
||||||
|
let username: string | undefined;
|
||||||
|
|
||||||
|
if (all) {
|
||||||
|
// Delete all messages in batches
|
||||||
|
const estimatedTotal = await this.estimateMessageCount(channel);
|
||||||
|
requestedCount = estimatedTotal;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const messages = await this.fetchMessages(channel, batchSize, lastMessageId);
|
||||||
|
|
||||||
|
if (messages.size === 0) break;
|
||||||
|
|
||||||
|
const { deleted, skipped } = await this.processBatch(
|
||||||
|
channel,
|
||||||
|
messages,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
totalDeleted += deleted;
|
||||||
|
totalSkipped += skipped;
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
if (progressCallback) {
|
||||||
|
await progressCallback({
|
||||||
|
current: totalDeleted,
|
||||||
|
total: estimatedTotal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we deleted fewer than we fetched, we've hit old messages
|
||||||
|
if (deleted < messages.size) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the ID of the last message for pagination
|
||||||
|
const lastMessage = Array.from(messages.values()).pop();
|
||||||
|
lastMessageId = lastMessage?.id;
|
||||||
|
|
||||||
|
// Delay to avoid rate limits
|
||||||
|
if (messages.size >= batchSize) {
|
||||||
|
await this.delay(batchDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Delete specific amount
|
||||||
|
const limit = Math.min(amount || 10, config.moderation.prune.maxAmount);
|
||||||
|
const messages = await this.fetchMessages(channel, limit, undefined);
|
||||||
|
|
||||||
|
const { deleted, skipped } = await this.processBatch(
|
||||||
|
channel,
|
||||||
|
messages,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
totalDeleted = deleted;
|
||||||
|
totalSkipped = skipped;
|
||||||
|
requestedCount = limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get username if filtering by user
|
||||||
|
if (userId && totalDeleted > 0) {
|
||||||
|
try {
|
||||||
|
const user = await channel.client.users.fetch(userId);
|
||||||
|
username = user.username;
|
||||||
|
} catch {
|
||||||
|
username = "Unknown User";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletedCount: totalDeleted,
|
||||||
|
requestedCount,
|
||||||
|
filtered: !!userId,
|
||||||
|
username,
|
||||||
|
skippedOld: totalSkipped > 0 ? totalSkipped : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch messages from a channel
|
||||||
|
*/
|
||||||
|
private static async fetchMessages(
|
||||||
|
channel: TextBasedChannel,
|
||||||
|
limit: number,
|
||||||
|
before?: string
|
||||||
|
): Promise<Collection<string, Message>> {
|
||||||
|
if (!('messages' in channel)) {
|
||||||
|
return new Collection();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await channel.messages.fetch({
|
||||||
|
limit,
|
||||||
|
before
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a batch of messages for deletion
|
||||||
|
*/
|
||||||
|
private static async processBatch(
|
||||||
|
channel: TextBasedChannel,
|
||||||
|
messages: Collection<string, Message>,
|
||||||
|
userId?: string
|
||||||
|
): Promise<{ deleted: number; skipped: number }> {
|
||||||
|
if (!('bulkDelete' in channel)) {
|
||||||
|
throw new Error("This channel type does not support bulk deletion");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by user if specified
|
||||||
|
let messagesToDelete = messages;
|
||||||
|
if (userId) {
|
||||||
|
messagesToDelete = messages.filter(msg => msg.author.id === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messagesToDelete.size === 0) {
|
||||||
|
return { deleted: 0, skipped: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// bulkDelete with filterOld=true will automatically skip messages >14 days
|
||||||
|
const deleted = await channel.bulkDelete(messagesToDelete, true);
|
||||||
|
const skipped = messagesToDelete.size - deleted.size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleted: deleted.size,
|
||||||
|
skipped
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during bulk delete:", error);
|
||||||
|
throw new Error("Failed to delete messages");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate the total number of messages in a channel
|
||||||
|
*/
|
||||||
|
static async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
|
||||||
|
if (!('messages' in channel)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch a small sample to get the oldest message
|
||||||
|
const sample = await channel.messages.fetch({ limit: 1 });
|
||||||
|
if (sample.size === 0) return 0;
|
||||||
|
|
||||||
|
// This is a rough estimate - Discord doesn't provide exact counts
|
||||||
|
// We'll return a conservative estimate
|
||||||
|
const oldestMessage = sample.first();
|
||||||
|
const channelAge = Date.now() - (oldestMessage?.createdTimestamp || Date.now());
|
||||||
|
const estimatedRate = 100; // messages per day (conservative)
|
||||||
|
const daysOld = channelAge / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
return Math.max(100, Math.round(daysOld * estimatedRate));
|
||||||
|
} catch {
|
||||||
|
return 100; // Default estimate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to delay execution
|
||||||
|
*/
|
||||||
|
private static delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/modules/moderation/prune.types.ts
Normal file
18
src/modules/moderation/prune.types.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export interface PruneOptions {
|
||||||
|
amount?: number;
|
||||||
|
userId?: string;
|
||||||
|
all?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PruneResult {
|
||||||
|
deletedCount: number;
|
||||||
|
requestedCount: number;
|
||||||
|
filtered: boolean;
|
||||||
|
username?: string;
|
||||||
|
skippedOld?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PruneProgress {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
115
src/modules/moderation/prune.view.ts
Normal file
115
src/modules/moderation/prune.view.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, Colors } from "discord.js";
|
||||||
|
import type { PruneResult, PruneProgress } from "./prune.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a confirmation message for prune operations
|
||||||
|
*/
|
||||||
|
export function getConfirmationMessage(
|
||||||
|
amount: number | 'all',
|
||||||
|
estimatedCount?: number
|
||||||
|
): { embeds: EmbedBuilder[], components: ActionRowBuilder<ButtonBuilder>[] } {
|
||||||
|
const isAll = amount === 'all';
|
||||||
|
const messageCount = isAll ? estimatedCount : amount;
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle("⚠️ Confirm Deletion")
|
||||||
|
.setDescription(
|
||||||
|
isAll
|
||||||
|
? `You are about to delete **ALL messages** in this channel.\n\n` +
|
||||||
|
`Estimated messages: **~${estimatedCount || 'Unknown'}**\n` +
|
||||||
|
`This action **cannot be undone**.`
|
||||||
|
: `You are about to delete **${amount} messages**.\n\n` +
|
||||||
|
`This action **cannot be undone**.`
|
||||||
|
)
|
||||||
|
.setColor(Colors.Orange)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const confirmButton = new ButtonBuilder()
|
||||||
|
.setCustomId("confirm_prune")
|
||||||
|
.setLabel("Confirm")
|
||||||
|
.setStyle(ButtonStyle.Danger);
|
||||||
|
|
||||||
|
const cancelButton = new ButtonBuilder()
|
||||||
|
.setCustomId("cancel_prune")
|
||||||
|
.setLabel("Cancel")
|
||||||
|
.setStyle(ButtonStyle.Secondary);
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||||
|
.addComponents(confirmButton, cancelButton);
|
||||||
|
|
||||||
|
return { embeds: [embed], components: [row] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a progress embed for ongoing deletions
|
||||||
|
*/
|
||||||
|
export function getProgressEmbed(progress: PruneProgress): EmbedBuilder {
|
||||||
|
const percentage = Math.round((progress.current / progress.total) * 100);
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle("🔄 Deleting Messages")
|
||||||
|
.setDescription(
|
||||||
|
`Progress: **${progress.current}/${progress.total}** (${percentage}%)\n\n` +
|
||||||
|
`Please wait...`
|
||||||
|
)
|
||||||
|
.setColor(Colors.Blue)
|
||||||
|
.setTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a success embed after deletion
|
||||||
|
*/
|
||||||
|
export function getSuccessEmbed(result: PruneResult): EmbedBuilder {
|
||||||
|
let description = `Successfully deleted **${result.deletedCount} messages**.`;
|
||||||
|
|
||||||
|
if (result.filtered && result.username) {
|
||||||
|
description = `Successfully deleted **${result.deletedCount} messages** from **${result.username}**.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.skippedOld && result.skippedOld > 0) {
|
||||||
|
description += `\n\n⚠️ **${result.skippedOld} messages** were older than 14 days and could not be deleted.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.deletedCount < result.requestedCount && !result.skippedOld) {
|
||||||
|
description += `\n\nℹ️ Only **${result.deletedCount}** messages were available to delete.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle("✅ Messages Deleted")
|
||||||
|
.setDescription(description)
|
||||||
|
.setColor(Colors.Green)
|
||||||
|
.setTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an error embed
|
||||||
|
*/
|
||||||
|
export function getPruneErrorEmbed(message: string): EmbedBuilder {
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle("❌ Prune Failed")
|
||||||
|
.setDescription(message)
|
||||||
|
.setColor(Colors.Red)
|
||||||
|
.setTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a warning embed
|
||||||
|
*/
|
||||||
|
export function getPruneWarningEmbed(message: string): EmbedBuilder {
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle("⚠️ Warning")
|
||||||
|
.setDescription(message)
|
||||||
|
.setColor(Colors.Yellow)
|
||||||
|
.setTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cancelled embed
|
||||||
|
*/
|
||||||
|
export function getCancelledEmbed(): EmbedBuilder {
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle("🚫 Deletion Cancelled")
|
||||||
|
.setDescription("Message deletion has been cancelled.")
|
||||||
|
.setColor(Colors.Grey)
|
||||||
|
.setTimestamp();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user