diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 94da7ae..7e2ef3c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,7 +23,7 @@ model Guild { ServiceAnnouncement_Enabled Boolean @default(false) ServiceAnnouncement_Channel String? - Purge_Limit Int @default(200) + Purge_Limit Int @default(300) } model User { diff --git a/src/discord/Purge.ts b/src/discord/Purge.ts index 0f37627..7b2a587 100644 --- a/src/discord/Purge.ts +++ b/src/discord/Purge.ts @@ -4,7 +4,8 @@ import { PermissionsBitField, GuildBasedChannel, BaseGuildTextChannel, - BaseInteraction + BaseInteraction, + GuildTextBasedChannel } from 'discord.js'; import { I18n } from 'i18n'; @@ -20,6 +21,11 @@ import Locale from '../services/Locale'; import Logger from '../libs/Logger'; import Prisma from '../providers/Prisma'; import Environment from '../providers/Environment'; +import { + checkBotPermissionsInChannel, + checkMemberPermissionsInChannel, + checkMemberPermissionsInGuild +} from '../utils/DiscordPermission'; const EMBEDS = { PURGE_INFO: (data: HybridInteractionMessage, locale: I18n) => { @@ -35,19 +41,65 @@ const EMBEDS = { user: data.getUser() }); }, - NO_PERMISSION: (data: HybridInteractionMessage, locale: I18n) => { - return makeErrorEmbed({ - title: locale.__('common.no_permissions'), - description: locale.__('common.no_permissions_description', { - PERMISSIONS: 'VIEW_CHANNEL, MANAGE_MESSAGES' - }), + PURGING_STAGE_1: (data: HybridInteractionMessage, locale: I18n, current: number, total: number) => { + return makeInfoEmbed({ + title: locale.__('purge.title'), + description: + locale.__('purge.purging_progress', { CURRENT: current.toString(), TOTAL: total.toString() }) + + '\n\n' + + locale.__('purge.purging_step_bulk_delete_done', { COUNT: current.toString() }), user: data.getUser() }); }, - PURGING: (data: HybridInteractionMessage, locale: I18n) => { + PURGING_STAGE_2: ( + data: HybridInteractionMessage, + locale: I18n, + current: number, + current_normal: number, + total: number + ) => { return makeInfoEmbed({ title: locale.__('purge.title'), - description: locale.__('purge.purging'), + description: + locale.__('purge.purging_progress', { + CURRENT: (current + current_normal).toString(), + TOTAL: total.toString() + }) + + '\n\n' + + locale.__('purge.purging_step_bulk_delete_done', { COUNT: current.toString() }) + + '\n' + + locale.__('purge.purging_step_normal_delete', { CURRENT: current_normal.toString() }), + user: data.getUser() + }); + }, + PURGING_DONE_EARLY: (data: HybridInteractionMessage, locale: I18n, current: number, total: number) => { + return makeSuccessEmbed({ + title: locale.__('purge.title'), + description: + locale.__('purge.purge_progress', { COUNT: current.toString(), TOTAL: total.toString() }) + + '\n\n' + + locale.__('purge.purging_step_bulk_delete_done', { COUNT: current.toString() }), + user: data.getUser() + }); + }, + PURGING_DONE: ( + data: HybridInteractionMessage, + locale: I18n, + current: number, + current_normal: number, + total: number + ) => { + return makeSuccessEmbed({ + title: locale.__('purge.title'), + description: + locale.__('purge.purge_progress', { + COUNT: (current + current_normal).toString(), + TOTAL: total.toString() + }) + + '\n\n' + + locale.__('purge.purging_step_bulk_delete_done', { COUNT: current.toString() }) + + '\n' + + locale.__('purge.purging_step_normal_delete_done', { COUNT: current_normal.toString() }), user: data.getUser() }); }, @@ -60,21 +112,21 @@ const EMBEDS = { }, PURGE_NAN_ERROR: (data: HybridInteractionMessage, locale: I18n) => { return makeErrorEmbed({ - title: locale.__('purge.error'), - description: locale.__('purge.error_nan'), + title: locale.__('purge.nan_error_title'), + description: locale.__('purge.nan_error_description'), user: data.getUser() }); }, - PURGE_OVER_LIMIT: (data: HybridInteractionMessage, locale: I18n) => { + PURGE_OVER_LIMIT: (data: HybridInteractionMessage, locale: I18n, limit: number) => { return makeErrorEmbed({ - title: locale.__('purge.error'), - description: locale.__('purge.error_over_limit'), + title: locale.__('common.member_no_permissions'), + description: locale.__('purge.error_over_limit', { MAX: limit.toString() }), user: data.getUser() }); }, - SUCCESSFULLY_PURGED: (data: HybridInteractionMessage, locale: I18n) => { + NOTHING_PURGED: (data: HybridInteractionMessage, locale: I18n) => { return makeSuccessEmbed({ - title: locale.__('purge.success'), + title: locale.__('purge.nothing_purged'), user: data.getUser() }); } @@ -94,34 +146,24 @@ export default class Purge extends DiscordModule { } async run(data: HybridInteractionMessage, args: any) { - // Disable this unfinished feature in production - if (Environment.get().NODE_ENV !== 'production') return; - const guild = data.getGuild(); if (!guild) return; + const member = data.getMember(); + if (!member) return; + + const channel = data.getGuildChannel() as GuildTextBasedChannel; + if (!channel) return; + const locale = await Locale.getGuildLocale(guild.id); let query; - const channel = data.getChannel(); - if (!channel) return; - if (data.isMessage()) { const message = data.getMessage(); if (!message.member) return; - if ( - !message.member.permissions.has([ - PermissionsBitField.Flags.ViewChannel, - PermissionsBitField.Flags.ManageMessages - ]) - ) - return await sendHybridInteractionMessageResponse(data, { - embeds: [EMBEDS.NO_PERMISSION(data, locale)] - }); - if (args.length === 0) return await sendHybridInteractionMessageResponse(data, { embeds: [EMBEDS.PURGE_INFO(data, locale)] @@ -130,20 +172,6 @@ export default class Purge extends DiscordModule { query = args.join(' '); } else if (data.isApplicationCommand()) { const interaction = data.getSlashCommand(); - if ( - !data - .getGuild()! - .members.cache.get(interaction.user.id) - ?.permissions.has([PermissionsBitField.Flags.ViewChannel]) && - data - .getGuild()! - .members.cache.get(interaction.user.id) - ?.permissions.has([PermissionsBitField.Flags.ManageMessages]) - ) - return await sendHybridInteractionMessageResponse(data, { - embeds: [EMBEDS.NO_PERMISSION(data, locale)] - }); - query = interaction.options.get('message', true).value?.toString(); } @@ -154,6 +182,28 @@ export default class Purge extends DiscordModule { }); } + if ( + !(await checkMemberPermissionsInChannel({ + member, + channel, + data, + locale, + permissions: [PermissionsBitField.Flags.ViewChannel, PermissionsBitField.Flags.ManageMessages] + })) + ) + return; + + if ( + !(await checkBotPermissionsInChannel({ + guild, + data, + locale, + channel, + permissions: [PermissionsBitField.Flags.ViewChannel, PermissionsBitField.Flags.ManageMessages] + })) + ) + return; + const PrismaGuild = await Prisma.client.guild.findFirst({ where: { id: guild.id } }); if (!PrismaGuild) return; @@ -161,13 +211,15 @@ export default class Purge extends DiscordModule { try { let totalMessagesToDelete = parseInt(query); - // if (totalMessagesToDelete > guildLimit) { - // return await sendHybridInteractionMessageResponse(data, { - // embeds: [EMBEDS.PURGE_OVER_LIMIT(data, locale)] - // }); - // } + if (totalMessagesToDelete > guildLimit) { + return await sendHybridInteractionMessageResponse(data, { + embeds: [EMBEDS.PURGE_OVER_LIMIT(data, locale, guildLimit)] + }); + } + + let totalMessageDeleted = 0; + let totalBulkDeletedMessages = 0; - let amountToDelete = totalMessagesToDelete > 100 ? 100 : totalMessagesToDelete; let messages; let statusMessage; if (data.isMessage()) { @@ -178,20 +230,22 @@ export default class Purge extends DiscordModule { .catch(() => {}); statusMessage = await sendMessage(channel, data.getUser()!, { - embeds: [EMBEDS.PURGING(data, locale)] + embeds: [EMBEDS.PURGING_STAGE_1(data, locale, totalBulkDeletedMessages, totalMessagesToDelete)] }); } else if (data.isApplicationCommand()) { - statusMessage = await data - .getMessageComponentInteraction() - .reply({ embeds: [EMBEDS.PURGING(data, locale)], fetchReply: true }); + statusMessage = await data.getMessageComponentInteraction().reply({ + embeds: [EMBEDS.PURGING_STAGE_1(data, locale, totalBulkDeletedMessages, totalMessagesToDelete)], + fetchReply: true + }); statusMessage = statusMessage as unknown as BaseInteraction; } statusMessage = new HybridInteractionMessage(statusMessage!); + let amountToDelete = totalMessagesToDelete > 100 ? 100 : totalMessagesToDelete; messages = await channel.messages.fetch({ limit: amountToDelete, - before: statusMessage!.getMessage().id + before: statusMessage.getMessage().id }); // Filter out statusMessage @@ -202,23 +256,70 @@ export default class Purge extends DiscordModule { // (msg) => msg.createdTimestamp < statusMessage!.getMessage().createdTimestamp //); - //TODO: Handle no messages to delete - if (!messages) { - return await sendHybridInteractionMessageResponse(data, { - embeds: [EMBEDS.SUCCESSFULLY_PURGED(data, locale)] - }); + // Edit the message + if (messages.size === 0) { + let embed = EMBEDS.NOTHING_PURGED(data, locale); + if (data.isMessage()) { + return await statusMessage.getMessage().edit({ embeds: [embed] }); + } else if (data.isApplicationCommand()) { + return await statusMessage.getMessageComponentInteraction().editReply({ embeds: [embed] }); + } } - const deletedMessages = await (channel as BaseGuildTextChannel).bulkDelete(messages, true); - let messageSize = deletedMessages.size; + while (totalMessageDeleted < totalMessagesToDelete) { + amountToDelete = + totalMessagesToDelete - totalMessageDeleted > 100 + ? 100 + : totalMessagesToDelete - totalMessageDeleted; + Logger.debug( + '[DiscordPurge]', + `Attempting to delete ${amountToDelete} messages. Total deleted: ${totalBulkDeletedMessages}/${totalMessagesToDelete}` + ); + + messages = await channel.messages.fetch({ + limit: amountToDelete, + before: statusMessage.getMessage().id + }); + + if (messages.size === 0) { + let embed = EMBEDS.PURGING_DONE_EARLY(data, locale, totalBulkDeletedMessages, amountToDelete); + if (data.isMessage()) { + return await statusMessage.getMessage().edit({ embeds: [embed] }); + } else if (data.isApplicationCommand()) { + return await statusMessage.getMessageComponentInteraction().editReply({ embeds: [embed] }); + } + } + + Logger.debug( + '[DiscordPurge]', + `Bulk deleting: ${messages.size}. Total deleted: ${totalBulkDeletedMessages}/${totalMessagesToDelete}` + ); + + const deletedMessages = await (channel as BaseGuildTextChannel).bulkDelete(messages, true); + totalMessageDeleted += deletedMessages.size; + totalBulkDeletedMessages += deletedMessages.size; + + let embed = EMBEDS.PURGING_STAGE_1(data, locale, totalBulkDeletedMessages, totalMessagesToDelete); + + if (data.isMessage()) { + await statusMessage.getMessage().edit({ embeds: [embed] }); + } else if (data.isApplicationCommand()) { + await statusMessage.getMessageComponentInteraction().editReply({ embeds: [embed] }); + } + + // Unable to delete all the messages? Break the loop and start normal deletion + if (deletedMessages.size !== messages.size) break; + } + + //let embed = EMBEDS.PURGING_DONE_EARLY(data, locale, totalBulkDeletedMessages, amountToDelete); Logger.debug( '[DiscordPurge]', - `Bulk deleted ${messageSize} messages. Total deleted: ${messageSize}/${totalMessagesToDelete}` + `Bulk deleted ${totalBulkDeletedMessages} messages. Total deleted: ${totalBulkDeletedMessages}/${totalMessagesToDelete}` ); - if (messageSize !== totalMessagesToDelete) { - const remainingMessages = totalMessagesToDelete - messageSize; + if (totalMessageDeleted !== totalMessagesToDelete) { + const remainingMessages = totalMessagesToDelete - totalMessageDeleted; const fetchLimit = 100; const fetchIterations = Math.ceil(remainingMessages / fetchLimit); @@ -231,27 +332,62 @@ export default class Purge extends DiscordModule { fetchedMessages = fetchedMessages.filter((msg) => msg.id !== statusMessage!.getMessage().id); + let messageCounter = 0; // Counter for tracking every 5 messages + for (const msg of fetchedMessages.values()) { await msg.delete(); - messageSize += 1; - Logger.debug('[DiscordPurge]', `Total deleted: ${messageSize}/${totalMessagesToDelete}`); + totalMessageDeleted += 1; + messageCounter += 1; + + if (messageCounter === 1 || messageCounter % 5 === 0) { + let embed = EMBEDS.PURGING_STAGE_2( + data, + locale, + totalBulkDeletedMessages, + totalMessageDeleted, + totalMessagesToDelete + ); + + if (data.isMessage()) { + await statusMessage.getMessage().edit({ embeds: [embed] }); + } else if (data.isApplicationCommand()) { + await statusMessage.getMessageComponentInteraction().editReply({ embeds: [embed] }); + } + + Logger.debug( + '[DiscordPurge]', + `Total deleted: ${totalMessageDeleted}/${totalMessagesToDelete}` + ); + } // One second delay to prevent rate limit await new Promise((resolve) => setTimeout(resolve, 2000)); } } - } - Logger.debug('[DiscordPurge]', `Total deleted: ${messageSize}/${totalMessagesToDelete}`); + Logger.debug('[DiscordPurge]', `Total deleted: ${totalMessageDeleted}/${totalMessagesToDelete}`); - if (data.isMessage()) { - return await sendMessage(channel, data.getUser()!, { - embeds: [EMBEDS.SUCCESSFULLY_PURGED(data, locale)] - }); - } else if (data.isApplicationCommand()) { - return await data - .getMessageComponentInteraction() - .editReply({ embeds: [EMBEDS.SUCCESSFULLY_PURGED(data, locale)] }); + let embed = EMBEDS.PURGING_DONE( + data, + locale, + totalBulkDeletedMessages, + totalMessageDeleted - totalBulkDeletedMessages, + amountToDelete + ); + if (data.isMessage()) { + return await statusMessage.getMessage().edit({ embeds: [embed] }); + } else if (data.isApplicationCommand()) { + return await statusMessage.getMessageComponentInteraction().editReply({ embeds: [embed] }); + } + } else { + Logger.debug('[DiscordPurge]', `Total deleted: ${totalMessageDeleted}/${totalMessagesToDelete}`); + + let embed = EMBEDS.PURGING_DONE_EARLY(data, locale, totalBulkDeletedMessages, amountToDelete); + if (data.isMessage()) { + return await statusMessage.getMessage().edit({ embeds: [embed] }); + } else if (data.isApplicationCommand()) { + return await statusMessage.getMessageComponentInteraction().editReply({ embeds: [embed] }); + } } } catch (err: any) { return await sendHybridInteractionMessageResponse(data, {