forked from syntaxbullet/AuroraBot-discord
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;
|
||||
messageId: string;
|
||||
};
|
||||
moderation: {
|
||||
prune: {
|
||||
maxAmount: number;
|
||||
confirmThreshold: number;
|
||||
batchSize: number;
|
||||
batchDelayMs: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Initial default config state
|
||||
@@ -124,7 +132,22 @@ const configSchema = z.object({
|
||||
terminal: z.object({
|
||||
channelId: 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() {
|
||||
|
||||
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