diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts index 8e6c6da2..24899916 100644 --- a/backend/src/pluginUtils.ts +++ b/backend/src/pluginUtils.ts @@ -7,6 +7,7 @@ import { Message, MessageCreateOptions, MessageMentionOptions, + ModalSubmitInteraction, PermissionsBitField, TextBasedChannel, } from "discord.js"; @@ -104,6 +105,7 @@ export async function sendSuccessMessage( channel: TextBasedChannel, body: string, allowedMentions?: MessageMentionOptions, + responseInteraction?: ModalSubmitInteraction, ): Promise { const emoji = pluginData.fullConfig.success_emoji || undefined; const formattedBody = successMessage(body, emoji); @@ -111,13 +113,19 @@ export async function sendSuccessMessage( ? { content: formattedBody, allowedMentions } : { content: formattedBody }; - return channel - .send({ ...content }) // Force line break - .catch((err) => { - const channelInfo = "guild" in channel ? `${channel.id} (${channel.guild.id})` : channel.id; - logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`); - return undefined; - }); + if (responseInteraction) { + await responseInteraction + .editReply({ content: formattedBody, embeds: [], components: [] }) + .catch((err) => logger.error(`Interaction reply failed: ${err}`)); + } else { + return channel + .send({ ...content }) // Force line break + .catch((err) => { + const channelInfo = "guild" in channel ? `${channel.id} (${channel.guild.id})` : channel.id; + logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`); + return undefined; + }); + } } export async function sendErrorMessage( @@ -125,6 +133,7 @@ export async function sendErrorMessage( channel: TextBasedChannel, body: string, allowedMentions?: MessageMentionOptions, + responseInteraction?: ModalSubmitInteraction, ): Promise { const emoji = pluginData.fullConfig.error_emoji || undefined; const formattedBody = errorMessage(body, emoji); @@ -132,13 +141,19 @@ export async function sendErrorMessage( ? { content: formattedBody, allowedMentions } : { content: formattedBody }; - return channel - .send({ ...content }) // Force line break - .catch((err) => { - const channelInfo = "guild" in channel ? `${channel.id} (${channel.guild.id})` : channel.id; - logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`); - return undefined; - }); + if (responseInteraction) { + await responseInteraction + .editReply({ content: formattedBody, embeds: [], components: [] }) + .catch((err) => logger.error(`Interaction reply failed: ${err}`)); + } else { + return channel + .send({ ...content }) // Force line break + .catch((err) => { + const channelInfo = "guild" in channel ? `${channel.id} (${channel.guild.id})` : channel.id; + logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`); + return undefined; + }); + } } export function getBaseUrl(pluginData: AnyPluginData) { diff --git a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts index 5461409d..1d8750b3 100644 --- a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts +++ b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts @@ -9,6 +9,7 @@ import { MutesPlugin } from "../Mutes/MutesPlugin"; import { UtilityPlugin } from "../Utility/UtilityPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { BanCmd } from "./commands/BanUserCtxCmd"; +import { CleanCmd } from "./commands/CleanMessageCtxCmd"; import { ModMenuCmd } from "./commands/ModMenuUserCtxCmd"; import { MuteCmd } from "./commands/MuteUserCtxCmd"; import { NoteCmd } from "./commands/NoteUserCtxCmd"; @@ -49,7 +50,7 @@ export const ContextMenuPlugin = zeppelinGuildPlugin()({ defaultOptions, - contextMenuCommands: [ModMenuCmd, NoteCmd, WarnCmd, MuteCmd, BanCmd], + contextMenuCommands: [ModMenuCmd, NoteCmd, WarnCmd, MuteCmd, BanCmd, CleanCmd], beforeLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/ContextMenus/actions/clean.ts b/backend/src/plugins/ContextMenus/actions/clean.ts index 628451c6..c5125721 100644 --- a/backend/src/plugins/ContextMenus/actions/clean.ts +++ b/backend/src/plugins/ContextMenus/actions/clean.ts @@ -1,4 +1,12 @@ -import { ActionRowBuilder, ButtonInteraction, ModalBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; +import { + ActionRowBuilder, + Message, + MessageContextMenuCommandInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; import { GuildPluginData } from "knub"; import { logger } from "../../../logger"; import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin"; @@ -9,7 +17,9 @@ export async function cleanAction( pluginData: GuildPluginData, amount: number, target: string, - interaction: ButtonInteraction, + targetMessage: Message, + targetChannel: string, + interaction: ModalSubmitInteraction, ) { const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ @@ -18,26 +28,27 @@ export async function cleanAction( }); const utility = pluginData.getPlugin(UtilityPlugin); - if (!userCfg.can_use || !(await utility.hasPermission(executingMember, interaction.channelId, "can_clean"))) { + if (!userCfg.can_use || !(await utility.hasPermission(executingMember, targetChannel, "can_clean"))) { await interaction .editReply({ content: "Cannot clean: insufficient permissions", embeds: [], components: [] }) .catch((err) => logger.error(`Clean interaction reply failed: ${err}`)); return; } - // TODO: Implement message cleaning await interaction .editReply({ - content: `TODO: Implementation incomplete`, + content: `Cleaning ${amount} messages from ${target}...`, embeds: [], components: [], }) .catch((err) => logger.error(`Clean interaction reply failed: ${err}`)); + + await utility.clean({ count: amount, channel: targetChannel, "response-interaction": interaction }, targetMessage); } export async function launchCleanActionModal( pluginData: GuildPluginData, - interaction: ButtonInteraction, + interaction: MessageContextMenuCommandInteraction, target: string, ) { const modalId = `${ModMenuActionType.CLEAN}:${interaction.id}`; @@ -50,7 +61,9 @@ export async function launchCleanActionModal( await interaction .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) .then(async (submitted) => { - await submitted.deferUpdate().catch((err) => logger.error(`Clean interaction defer failed: ${err}`)); + await submitted + .deferReply({ ephemeral: true }) + .catch((err) => logger.error(`Clean interaction defer failed: ${err}`)); const amount = submitted.fields.getTextInputValue("amount"); if (isNaN(Number(amount))) { @@ -60,7 +73,14 @@ export async function launchCleanActionModal( return; } - await cleanAction(pluginData, Number(amount), target, interaction); + await cleanAction( + pluginData, + Number(amount), + target, + interaction.targetMessage, + interaction.channelId, + submitted, + ); }) .catch((err) => logger.error(`Clean modal interaction failed: ${err}`)); } diff --git a/backend/src/plugins/ContextMenus/commands/CleanMessageCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/CleanMessageCtxCmd.ts new file mode 100644 index 00000000..0902ab00 --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/CleanMessageCtxCmd.ts @@ -0,0 +1,11 @@ +import { PermissionFlagsBits } from "discord.js"; +import { guildPluginMessageContextMenuCommand } from "knub"; +import { launchCleanActionModal } from "../actions/clean"; + +export const CleanCmd = guildPluginMessageContextMenuCommand({ + name: "Clean", + defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(), + async run({ pluginData, interaction }) { + await launchCleanActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/Utility/commands/CleanCmd.ts b/backend/src/plugins/Utility/commands/CleanCmd.ts index 18d4efa2..e47dc413 100644 --- a/backend/src/plugins/Utility/commands/CleanCmd.ts +++ b/backend/src/plugins/Utility/commands/CleanCmd.ts @@ -1,4 +1,4 @@ -import { Message, Snowflake, TextChannel, User } from "discord.js"; +import { Message, ModalSubmitInteraction, Snowflake, TextChannel, User } from "discord.js"; import { GuildPluginData } from "knub"; import { allowTimeout } from "../../../RegExpRunner"; import { commandTypeHelpers as ct } from "../../../commandTypes"; @@ -77,17 +77,24 @@ export interface CleanArgs { "has-invites"?: boolean; match?: RegExp; "to-id"?: string; + "response-interaction"?: ModalSubmitInteraction; } export async function cleanCmd(pluginData: GuildPluginData, args: CleanArgs | any, msg) { if (args.count > MAX_CLEAN_COUNT || args.count <= 0) { - sendErrorMessage(pluginData, msg.channel, `Clean count must be between 1 and ${MAX_CLEAN_COUNT}`); + sendErrorMessage( + pluginData, + msg.channel, + `Clean count must be between 1 and ${MAX_CLEAN_COUNT}`, + undefined, + args["response-interaction"], + ); return; } const targetChannel = args.channel ? pluginData.guild.channels.cache.get(args.channel as Snowflake) : msg.channel; if (!targetChannel?.isTextBased()) { - sendErrorMessage(pluginData, msg.channel, `Invalid channel specified`); + sendErrorMessage(pluginData, msg.channel, `Invalid channel specified`, undefined, args["response-interaction"]); return; } @@ -99,12 +106,21 @@ export async function cleanCmd(pluginData: GuildPluginData, a categoryId: targetChannel.parentId, }); if (configForTargetChannel.can_clean !== true) { - sendErrorMessage(pluginData, msg.channel, `Missing permissions to use clean on that channel`); + sendErrorMessage( + pluginData, + msg.channel, + `Missing permissions to use clean on that channel`, + undefined, + args["response-interaction"], + ); return; } } - const cleaningMessage = msg.channel.send("Cleaning..."); + let cleaningMessage: Message | undefined = undefined; + if (!args["response-interaction"]) { + cleaningMessage = await msg.channel.send("Cleaning..."); + } const messagesToClean: Message[] = []; let beforeId = msg.id; @@ -202,19 +218,31 @@ export async function cleanCmd(pluginData: GuildPluginData, a } } - responseMsg = await sendSuccessMessage(pluginData, msg.channel, responseText); + responseMsg = await sendSuccessMessage( + pluginData, + msg.channel, + responseText, + undefined, + args["response-interaction"], + ); } else { const responseText = `Found no messages to clean${note ? ` (${note})` : ""}!`; - responseMsg = await sendErrorMessage(pluginData, msg.channel, responseText); + responseMsg = await sendErrorMessage( + pluginData, + msg.channel, + responseText, + undefined, + args["response-interaction"], + ); } - await (await cleaningMessage).delete(); + cleaningMessage?.delete(); if (targetChannel.id === msg.channel.id) { // Delete the !clean command and the bot response if a different channel wasn't specified // (so as not to spam the cleaned channel with the command itself) + msg.delete().catch(noop); setTimeout(() => { - msg.delete().catch(noop); responseMsg?.delete().catch(noop); }, CLEAN_COMMAND_DELETE_DELAY); }