From 37ac0ee9340260640634039c06862c485e099468 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 24 Dec 2025 20:45:40 +0100 Subject: [PATCH] feat: implement message pruning command with dedicated service and UI components. --- src/commands/admin/prune.ts | 179 +++++++++++++++++++++ src/lib/config.ts | 25 ++- src/modules/moderation/prune.service.ts | 198 ++++++++++++++++++++++++ src/modules/moderation/prune.types.ts | 18 +++ src/modules/moderation/prune.view.ts | 115 ++++++++++++++ 5 files changed, 534 insertions(+), 1 deletion(-) create mode 100644 src/commands/admin/prune.ts create mode 100644 src/modules/moderation/prune.service.ts create mode 100644 src/modules/moderation/prune.types.ts create mode 100644 src/modules/moderation/prune.view.ts diff --git a/src/commands/admin/prune.ts b/src/commands/admin/prune.ts new file mode 100644 index 0000000..7c9be5f --- /dev/null +++ b/src/commands/admin/prune.ts @@ -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)] + }); + } + } +}); diff --git a/src/lib/config.ts b/src/lib/config.ts index aad2b41..18aef06 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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() { diff --git a/src/modules/moderation/prune.service.ts b/src/modules/moderation/prune.service.ts new file mode 100644 index 0000000..539a360 --- /dev/null +++ b/src/modules/moderation/prune.service.ts @@ -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 + ): Promise { + // 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> { + 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, + 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 { + 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 { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/src/modules/moderation/prune.types.ts b/src/modules/moderation/prune.types.ts new file mode 100644 index 0000000..7b1e051 --- /dev/null +++ b/src/modules/moderation/prune.types.ts @@ -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; +} diff --git a/src/modules/moderation/prune.view.ts b/src/modules/moderation/prune.view.ts new file mode 100644 index 0000000..ba93fb2 --- /dev/null +++ b/src/modules/moderation/prune.view.ts @@ -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[] } { + 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() + .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(); +}