From 771ed76f64e0c330259dbce3a8c6d4c3a7fc14db Mon Sep 17 00:00:00 2001 From: Obliie Date: Sat, 15 Jul 2023 21:34:26 +0100 Subject: [PATCH 1/9] feat: Context menu mod menu command --- backend/src/data/GuildCases.ts | 15 + .../plugins/ContextMenus/ContextMenuPlugin.ts | 40 ++- .../src/plugins/ContextMenus/actions/ban.ts | 97 ++++++ .../src/plugins/ContextMenus/actions/clean.ts | 66 ++-- .../src/plugins/ContextMenus/actions/mute.ts | 78 ++++- .../src/plugins/ContextMenus/actions/note.ts | 88 +++++ .../plugins/ContextMenus/actions/userInfo.ts | 28 -- .../src/plugins/ContextMenus/actions/warn.ts | 80 +++++ .../ContextMenus/commands/ModMenuCmd.ts | 319 ++++++++++++++++++ .../ContextMenus/events/ContextClickedEvt.ts | 12 - backend/src/plugins/ContextMenus/types.ts | 49 ++- .../ContextMenus/utils/contextRouter.ts | 13 - .../utils/hardcodedContextOptions.ts | 23 -- .../ContextMenus/utils/loadAllCommands.ts | 39 --- .../plugins/ModActions/ModActionsPlugin.ts | 29 +- .../ModActions/functions/hasModActionPerm.ts | 35 ++ .../ModActions/functions/hasMutePerm.ts | 11 - 17 files changed, 818 insertions(+), 204 deletions(-) create mode 100644 backend/src/plugins/ContextMenus/actions/ban.ts create mode 100644 backend/src/plugins/ContextMenus/actions/note.ts delete mode 100644 backend/src/plugins/ContextMenus/actions/userInfo.ts create mode 100644 backend/src/plugins/ContextMenus/actions/warn.ts create mode 100644 backend/src/plugins/ContextMenus/commands/ModMenuCmd.ts delete mode 100644 backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts delete mode 100644 backend/src/plugins/ContextMenus/utils/contextRouter.ts delete mode 100644 backend/src/plugins/ContextMenus/utils/hardcodedContextOptions.ts delete mode 100644 backend/src/plugins/ContextMenus/utils/loadAllCommands.ts create mode 100644 backend/src/plugins/ModActions/functions/hasModActionPerm.ts delete mode 100644 backend/src/plugins/ModActions/functions/hasMutePerm.ts diff --git a/backend/src/data/GuildCases.ts b/backend/src/data/GuildCases.ts index d0e7778a..eb5ed6b7 100644 --- a/backend/src/data/GuildCases.ts +++ b/backend/src/data/GuildCases.ts @@ -83,6 +83,21 @@ export class GuildCases extends BaseGuildRepository { }); } + async getRecentByUserId(userId: string, count: number, skip = 0): Promise { + return this.cases.find({ + relations: this.getRelations(), + where: { + guild_id: this.guildId, + user_id: userId, + }, + skip, + take: count, + order: { + case_number: "DESC", + }, + }); + } + async getTotalCasesByModId(modId: string): Promise { return this.cases.count({ where: { diff --git a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts index c41e8c09..dc08389e 100644 --- a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts +++ b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts @@ -1,32 +1,30 @@ import { PluginOptions } from "knub"; -import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks"; +import { GuildCases } from "../../data/GuildCases"; import { makeIoTsConfigParser } from "../../pluginUtils"; +import { trimPluginDescription } from "../../utils"; +import { CasesPlugin } from "../Cases/CasesPlugin"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { MutesPlugin } from "../Mutes/MutesPlugin"; import { UtilityPlugin } from "../Utility/UtilityPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; -import { ContextClickedEvt } from "./events/ContextClickedEvt"; +import { ModMenuCmd } from "./commands/ModMenuCmd"; import { ConfigSchema, ContextMenuPluginType } from "./types"; -import { loadAllCommands } from "./utils/loadAllCommands"; const defaultOptions: PluginOptions = { config: { can_use: false, - user_muteindef: false, - user_mute1d: false, - user_mute1h: false, - user_info: false, + can_open_mod_menu: false, - message_clean10: false, - message_clean25: false, - message_clean50: false, + log_channel: null, }, overrides: [ { level: ">=50", config: { can_use: true, + + can_open_mod_menu: true, }, }, ], @@ -34,24 +32,24 @@ const defaultOptions: PluginOptions = { export const ContextMenuPlugin = zeppelinGuildPlugin()({ name: "context_menu", - showInDocs: false, + showInDocs: true, + info: { + prettyName: "Context Menus", + description: trimPluginDescription(` + This plugin provides command shortcuts via context menus + `), + configSchema: ConfigSchema, + }, - dependencies: () => [MutesPlugin, LogsPlugin, UtilityPlugin], + dependencies: () => [CasesPlugin, MutesPlugin, LogsPlugin, UtilityPlugin], configParser: makeIoTsConfigParser(ConfigSchema), defaultOptions, - // prettier-ignore - events: [ - ContextClickedEvt, - ], + contextMenuCommands: [ModMenuCmd], beforeLoad(pluginData) { const { state, guild } = pluginData; - state.contextMenuLinks = new GuildContextMenuLinks(guild.id); - }, - - afterLoad(pluginData) { - loadAllCommands(pluginData); + state.cases = GuildCases.getGuildInstance(guild.id); }, }); diff --git a/backend/src/plugins/ContextMenus/actions/ban.ts b/backend/src/plugins/ContextMenus/actions/ban.ts new file mode 100644 index 00000000..38ec91a1 --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/ban.ts @@ -0,0 +1,97 @@ +import { + ActionRowBuilder, + ButtonInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import humanizeDuration from "humanize-duration"; +import { GuildPluginData } from "knub"; +import { canActOn } from "src/pluginUtils"; +import { ModActionsPlugin } from "src/plugins/ModActions/ModActionsPlugin"; +import { convertDelayStringToMS, renderUserUsername } from "../../../utils"; +import { CaseArgs } from "../../Cases/types"; +import { MODAL_TIMEOUT } from "../commands/ModMenuCmd"; +import { ContextMenuPluginType } from "../types"; + +async function banAction( + pluginData: GuildPluginData, + duration: string | undefined, + reason: string | undefined, + target: string, + interaction: ButtonInteraction, +) { + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + + const modactions = pluginData.getPlugin(ModActionsPlugin); + if (!userCfg.can_use || !(await modactions.hasBanPermission(executingMember, interaction.channelId))) { + await interaction.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); + return; + } + + const targetMember = await pluginData.guild.members.fetch(target); + if (!canActOn(pluginData, executingMember, targetMember)) { + await interaction.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); + return; + } + + const caseArgs: Partial = { + modId: executingMember.id, + }; + + const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; + const result = await modactions.banUserId(target, reason, { caseArgs }, durationMs); + if (result.status === "failed") { + await interaction.editReply({ content: "ERROR: Failed to ban user", embeds: [], components: [] }); + return; + } + + const userName = renderUserUsername(targetMember.user); + const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; + const banMessage = `Banned **${userName}** ${ + durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" + } (Case #${result.case.case_number})${messageResultText}`; + + await interaction.editReply({ content: banMessage, embeds: [], components: [] }); +} + +export async function launchBanActionModal( + pluginData: GuildPluginData, + interaction: ButtonInteraction, + target: string, +) { + const modal = new ModalBuilder().setCustomId("ban").setTitle("Ban"); + + const durationIn = new TextInputBuilder() + .setCustomId("duration") + .setLabel("Duration (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Short); + + const reasonIn = new TextInputBuilder() + .setCustomId("reason") + .setLabel("Reason (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); + + const durationRow = new ActionRowBuilder().addComponents(durationIn); + const reasonRow = new ActionRowBuilder().addComponents(reasonIn); + + modal.addComponents(durationRow, reasonRow); + + await interaction.showModal(modal); + const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); + if (submitted) { + await submitted.deferUpdate(); + + const duration = submitted.fields.getTextInputValue("duration"); + const reason = submitted.fields.getTextInputValue("reason"); + + await banAction(pluginData, duration, reason, target, interaction); + } +} diff --git a/backend/src/plugins/ContextMenus/actions/clean.ts b/backend/src/plugins/ContextMenus/actions/clean.ts index a9d2384b..6274f230 100644 --- a/backend/src/plugins/ContextMenus/actions/clean.ts +++ b/backend/src/plugins/ContextMenus/actions/clean.ts @@ -1,16 +1,22 @@ -import { ContextMenuCommandInteraction, TextChannel } from "discord.js"; +import { + ActionRowBuilder, + ButtonInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; import { GuildPluginData } from "knub"; -import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin"; -import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { MODAL_TIMEOUT } from "../commands/ModMenuCmd"; import { ContextMenuPluginType } from "../types"; export async function cleanAction( pluginData: GuildPluginData, amount: number, - interaction: ContextMenuCommandInteraction, + target: string, + interaction: ButtonInteraction, ) { - await interaction.deferReply({ ephemeral: true }); const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -19,32 +25,42 @@ export async function cleanAction( const utility = pluginData.getPlugin(UtilityPlugin); if (!userCfg.can_use || !(await utility.hasPermission(executingMember, interaction.channelId, "can_clean"))) { - await interaction.followUp({ content: "Cannot clean: insufficient permissions" }); + await interaction.editReply({ content: "Cannot clean: insufficient permissions", embeds: [], components: [] }); return; } - const targetMessage = interaction.channel - ? await interaction.channel.messages.fetch(interaction.targetId) - : await (pluginData.guild.channels.resolve(interaction.channelId) as TextChannel).messages.fetch( - interaction.targetId, - ); + // TODO: Implement message cleaning + await interaction.editReply({ + content: `TODO: Implementation incomplete`, + embeds: [], + components: [], + }); +} - const targetUserOnly = false; - const deletePins = false; - const user = undefined; +export async function launchCleanActionModal( + pluginData: GuildPluginData, + interaction: ButtonInteraction, + target: string, +) { + const modal = new ModalBuilder().setCustomId("clean").setTitle("Clean"); - try { - await interaction.followUp(`Cleaning... Amount: ${amount}, User Only: ${targetUserOnly}, Pins: ${deletePins}`); - utility.clean({ count: amount, user, channel: targetMessage.channel.id, "delete-pins": deletePins }, targetMessage); - } catch (e) { - await interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" }); + const amountIn = new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short); - if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { - pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Failed to clean in <#${interaction.channelId}> in ContextMenu action \`clean\`:_ ${e}`, - }); - } else { - throw e; + const amountRow = new ActionRowBuilder().addComponents(amountIn); + + modal.addComponents(amountRow); + + await interaction.showModal(modal); + const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); + if (submitted) { + await submitted.deferUpdate(); + + const amount = submitted.fields.getTextInputValue("amount"); + if (isNaN(Number(amount))) { + interaction.editReply({ content: `ERROR: Amount ${amount} is invalid`, embeds: [], components: [] }); + return; } + + await cleanAction(pluginData, Number(amount), target, interaction); } } diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts index 7fe2f5d5..a86a5ddd 100644 --- a/backend/src/plugins/ContextMenus/actions/mute.ts +++ b/backend/src/plugins/ContextMenus/actions/mute.ts @@ -1,4 +1,11 @@ -import { ContextMenuCommandInteraction } from "discord.js"; +import { + ActionRowBuilder, + ButtonInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; import { canActOn } from "src/pluginUtils"; @@ -8,14 +15,16 @@ import { convertDelayStringToMS } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { MutesPlugin } from "../../Mutes/MutesPlugin"; +import { MODAL_TIMEOUT } from "../commands/ModMenuCmd"; import { ContextMenuPluginType } from "../types"; -export async function muteAction( +async function muteAction( pluginData: GuildPluginData, duration: string | undefined, - interaction: ContextMenuCommandInteraction, + reason: string | undefined, + target: string, + interaction: ButtonInteraction, ) { - await interaction.deferReply({ ephemeral: true }); const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -24,43 +33,76 @@ export async function muteAction( const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasMutePermission(executingMember, interaction.channelId))) { - await interaction.followUp({ content: "Cannot mute: insufficient permissions" }); + await interaction.editReply({ content: "Cannot mute: insufficient permissions", embeds: [], components: [] }); return; } - const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; - const mutes = pluginData.getPlugin(MutesPlugin); - const userId = interaction.targetId; - const targetMember = await pluginData.guild.members.fetch(interaction.targetId); - + const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { - await interaction.followUp({ ephemeral: true, content: "Cannot mute: insufficient permissions" }); + await interaction.editReply({ content: "Cannot mute: insufficient permissions", embeds: [], components: [] }); return; } const caseArgs: Partial = { modId: executingMember.id, }; + const mutes = pluginData.getPlugin(MutesPlugin); + const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; try { - const result = await mutes.muteUser(userId, durationMs, "Context Menu Action", { caseArgs }); + const result = await mutes.muteUser(target, durationMs, reason, { caseArgs }); + const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; const muteMessage = `Muted **${result.case.user_name}** ${ durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" - } (Case #${result.case.case_number}) (user notified via ${ - result.notifyResult.method ?? "dm" - })\nPlease update the new case with the \`update\` command`; + } (Case #${result.case.case_number})${messageResultText}`; - await interaction.followUp({ ephemeral: true, content: muteMessage }); + await interaction.editReply({ content: muteMessage, embeds: [], components: [] }); } catch (e) { - await interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" }); + await interaction.editReply({ content: "Plugin error, please check your BOT_ALERTs", embeds: [], components: [] }); if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Failed to mute <@!${userId}> in ContextMenu action \`mute\` because a mute role has not been specified in server config`, + body: `Failed to mute <@!${target}> in ContextMenu action \`mute\` because a mute role has not been specified in server config`, }); } else { throw e; } } } + +export async function launchMuteActionModal( + pluginData: GuildPluginData, + interaction: ButtonInteraction, + target: string, +) { + const modal = new ModalBuilder().setCustomId("mute").setTitle("Mute"); + + const durationIn = new TextInputBuilder() + .setCustomId("duration") + .setLabel("Duration (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Short); + + const reasonIn = new TextInputBuilder() + .setCustomId("reason") + .setLabel("Reason (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); + + const durationRow = new ActionRowBuilder().addComponents(durationIn); + const reasonRow = new ActionRowBuilder().addComponents(reasonIn); + + modal.addComponents(durationRow, reasonRow); + + await interaction.showModal(modal); + const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); + if (submitted) { + await submitted.deferUpdate(); + + const duration = submitted.fields.getTextInputValue("duration"); + const reason = submitted.fields.getTextInputValue("reason"); + + await muteAction(pluginData, duration, reason, target, interaction); + } +} diff --git a/backend/src/plugins/ContextMenus/actions/note.ts b/backend/src/plugins/ContextMenus/actions/note.ts new file mode 100644 index 00000000..13d855d5 --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/note.ts @@ -0,0 +1,88 @@ +import { + ActionRowBuilder, + ButtonInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { GuildPluginData } from "knub"; +import { canActOn } from "src/pluginUtils"; +import { ModActionsPlugin } from "src/plugins/ModActions/ModActionsPlugin"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; +import { renderUserUsername } from "../../../utils"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { MODAL_TIMEOUT } from "../commands/ModMenuCmd"; +import { ContextMenuPluginType } from "../types"; + +async function noteAction( + pluginData: GuildPluginData, + reason: string, + target: string, + interaction: ButtonInteraction, +) { + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + + const modactions = pluginData.getPlugin(ModActionsPlugin); + if (!userCfg.can_use || !(await modactions.hasNotePermission(executingMember, interaction.channelId))) { + await interaction.editReply({ content: "Cannot note: insufficient permissions", embeds: [], components: [] }); + return; + } + + const targetMember = await pluginData.guild.members.fetch(target); + if (!canActOn(pluginData, executingMember, targetMember)) { + await interaction.editReply({ content: "Cannot mute: insufficient permissions", embeds: [], components: [] }); + return; + } + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: target, + modId: executingMember.id, + type: CaseTypes.Note, + reason, + }); + + pluginData.getPlugin(LogsPlugin).logMemberNote({ + mod: interaction.user, + user: targetMember.user, + caseNumber: createdCase.case_number, + reason, + }); + + const userName = renderUserUsername(targetMember.user); + await interaction.editReply({ + content: `Note added on **${userName}** (Case #${createdCase.case_number})`, + embeds: [], + components: [], + }); +} + +export async function launchNoteActionModal( + pluginData: GuildPluginData, + interaction: ButtonInteraction, + target: string, +) { + const modal = new ModalBuilder().setCustomId("note").setTitle("Note"); + + const reasonIn = new TextInputBuilder().setCustomId("reason").setLabel("Note").setStyle(TextInputStyle.Paragraph); + + const reasonRow = new ActionRowBuilder().addComponents(reasonIn); + + modal.addComponents(reasonRow); + + await interaction.showModal(modal); + const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); + if (submitted) { + await submitted.deferUpdate(); + + const reason = submitted.fields.getTextInputValue("reason"); + + await noteAction(pluginData, reason, target, interaction); + } +} diff --git a/backend/src/plugins/ContextMenus/actions/userInfo.ts b/backend/src/plugins/ContextMenus/actions/userInfo.ts deleted file mode 100644 index e445e1c8..00000000 --- a/backend/src/plugins/ContextMenus/actions/userInfo.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ContextMenuCommandInteraction } from "discord.js"; -import { GuildPluginData } from "knub"; -import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin"; -import { ContextMenuPluginType } from "../types"; - -export async function userInfoAction( - pluginData: GuildPluginData, - interaction: ContextMenuCommandInteraction, -) { - await interaction.deferReply({ ephemeral: true }); - const executingMember = await pluginData.guild.members.fetch(interaction.user.id); - const userCfg = await pluginData.config.getMatchingConfig({ - channelId: interaction.channelId, - member: executingMember, - }); - const utility = pluginData.getPlugin(UtilityPlugin); - - if (userCfg.can_use && (await utility.hasPermission(executingMember, interaction.channelId, "can_userinfo"))) { - const embed = await utility.userInfo(interaction.targetId, interaction.user.id); - if (!embed) { - await interaction.followUp({ content: "Cannot info: internal error" }); - return; - } - await interaction.followUp({ embeds: [embed] }); - } else { - await interaction.followUp({ content: "Cannot info: insufficient permissions" }); - } -} diff --git a/backend/src/plugins/ContextMenus/actions/warn.ts b/backend/src/plugins/ContextMenus/actions/warn.ts new file mode 100644 index 00000000..bbfa66c5 --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/warn.ts @@ -0,0 +1,80 @@ +import { + ActionRowBuilder, + ButtonInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { GuildPluginData } from "knub"; +import { canActOn } from "src/pluginUtils"; +import { ModActionsPlugin } from "src/plugins/ModActions/ModActionsPlugin"; +import { renderUserUsername } from "../../../utils"; +import { CaseArgs } from "../../Cases/types"; +import { MODAL_TIMEOUT } from "../commands/ModMenuCmd"; +import { ContextMenuPluginType } from "../types"; + +async function warnAction( + pluginData: GuildPluginData, + reason: string, + target: string, + interaction: ButtonInteraction, +) { + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + + const modactions = pluginData.getPlugin(ModActionsPlugin); + if (!userCfg.can_use || !(await modactions.hasWarnPermission(executingMember, interaction.channelId))) { + await interaction.editReply({ content: "Cannot warn: insufficient permissions", embeds: [], components: [] }); + return; + } + + const targetMember = await pluginData.guild.members.fetch(target); + if (!canActOn(pluginData, executingMember, targetMember)) { + await interaction.editReply({ content: "Cannot mute: insufficient permissions", embeds: [], components: [] }); + return; + } + + const caseArgs: Partial = { + modId: executingMember.id, + }; + + const result = await modactions.warnMember(targetMember, reason, { caseArgs }); + if (result.status === "failed") { + await interaction.editReply({ content: "Failed to warn user", embeds: [], components: [] }); + return; + } + + const userName = renderUserUsername(targetMember.user); + const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; + const muteMessage = `Warned **${userName}** (Case #${result.case.case_number})${messageResultText}`; + + await interaction.editReply({ content: muteMessage, embeds: [], components: [] }); +} + +export async function launchWarnActionModal( + pluginData: GuildPluginData, + interaction: ButtonInteraction, + target: string, +) { + const modal = new ModalBuilder().setCustomId("warn").setTitle("Warn"); + + const reasonIn = new TextInputBuilder().setCustomId("reason").setLabel("Reason").setStyle(TextInputStyle.Paragraph); + + const reasonRow = new ActionRowBuilder().addComponents(reasonIn); + + modal.addComponents(reasonRow); + + await interaction.showModal(modal); + const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); + if (submitted) { + await submitted.deferUpdate(); + + const reason = submitted.fields.getTextInputValue("reason"); + + await warnAction(pluginData, reason, target, interaction); + } +} diff --git a/backend/src/plugins/ContextMenus/commands/ModMenuCmd.ts b/backend/src/plugins/ContextMenus/commands/ModMenuCmd.ts new file mode 100644 index 00000000..faf9bd2f --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/ModMenuCmd.ts @@ -0,0 +1,319 @@ +import { + APIEmbed, + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ContextMenuCommandInteraction, + User, +} from "discord.js"; +import { GuildPluginData, guildPluginUserContextMenuCommand } from "knub"; +import { Case } from "../../../data/entities/Case"; +import { getUserInfoEmbed } from "../../../plugins/Utility/functions/getUserInfoEmbed"; +import { SECONDS, UnknownUser, emptyEmbedValue, renderUserUsername, resolveUser, trimLines } from "../../../utils"; +import { asyncMap } from "../../../utils/async"; +import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields"; +import { getGuildPrefix } from "../../../utils/getGuildPrefix"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { UtilityPlugin } from "../../Utility/UtilityPlugin"; +import { launchBanActionModal } from "../actions/ban"; +import { launchCleanActionModal } from "../actions/clean"; +import { launchMuteActionModal } from "../actions/mute"; +import { launchNoteActionModal } from "../actions/note"; +import { launchWarnActionModal } from "../actions/warn"; +import { + ContextMenuPluginType, + LoadModMenuPageFn, + ModMenuActionOpts, + ModMenuActionType, + ModMenuNavigationType, +} from "../types"; + +export const MODAL_TIMEOUT = 60 * SECONDS; +const MOD_MENU_TIMEOUT = 60 * SECONDS; +const CASES_PER_PAGE = 10; + +export const ModMenuCmd = guildPluginUserContextMenuCommand({ + name: "Mod Menu", + async run({ pluginData, interaction }) { + await interaction.deferReply({ ephemeral: true }); + + // Run permission checks for executing user. + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + const utility = pluginData.getPlugin(UtilityPlugin); + if ( + !userCfg.can_use || + (await !utility.hasPermission(executingMember, interaction.channelId, "can_open_mod_menu")) + ) { + await interaction.followUp({ content: "Error: Insufficient Permissions" }); + return; + } + + const user = await resolveUser(pluginData.client, interaction.targetId); + if (!user.id) { + await interaction.followUp("Error: User not found"); + return; + } + + // Load cases and display mod menu + const cases: Case[] = await pluginData.state.cases.with("notes").getByUserId(user.id); + const userName = + user instanceof UnknownUser && cases.length ? cases[cases.length - 1].user_name : renderUserUsername(user); + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const totalCases = cases.length; + const totalPages: number = Math.max(Math.ceil(totalCases / CASES_PER_PAGE), 1); + const prefix = getGuildPrefix(pluginData); + const infoEmbed = await getUserInfoEmbed(pluginData, user.id, false); + displayModMenu( + pluginData, + interaction, + totalPages, + async (page) => { + const pageCases: Case[] = await pluginData.state.cases + .with("notes") + .getRecentByUserId(user.id, CASES_PER_PAGE, (page - 1) * CASES_PER_PAGE); + const lines = await asyncMap(pageCases, (c) => casesPlugin.getCaseSummary(c, true, interaction.targetId)); + + const firstCaseNum = (page - 1) * CASES_PER_PAGE + 1; + const lastCaseNum = Math.min(page * CASES_PER_PAGE, totalCases); + const title = + lines.length == 0 + ? `${userName}` + : `Most recent cases for ${userName} | ${firstCaseNum}-${lastCaseNum} of ${totalCases}`; + const embedFields = + lines.length == 0 + ? [ + { + name: `**No cases found**`, + value: "", + }, + ] + : [ + ...getChunkedEmbedFields( + emptyEmbedValue, + lines.length == 0 ? `No cases found for **${userName}**` : lines.join("\n"), + ), + { + name: emptyEmbedValue, + value: trimLines(` + Use \`${prefix}case \` to see more information about an individual case + `), + }, + ]; + + const embed = { + author: { + name: title, + icon_url: user instanceof User ? user.displayAvatarURL() : undefined, + }, + fields: embedFields, + footer: { text: `Page ${page}/${totalPages}` }, + } satisfies APIEmbed; + + return embed; + }, + infoEmbed, + ); + }, +}); + +async function displayModMenu( + pluginData: GuildPluginData, + interaction: ContextMenuCommandInteraction, + totalPages: number, + loadPage: LoadModMenuPageFn, + infoEmbed: APIEmbed | null, +) { + if (interaction.deferred == false) { + await interaction.deferReply(); + } + + const firstButton = new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("<<") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.FIRST })) + .setDisabled(true); + const prevButton = new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("<") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.PREV })) + .setDisabled(true); + const infoButton = new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("Info") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO })) + .setDisabled(infoEmbed != null ? false : true); + const nextButton = new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel(">") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.NEXT })) + .setDisabled(totalPages > 1 ? false : true); + const lastButton = new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel(">>") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.LAST })) + .setDisabled(totalPages > 1 ? false : true); + const navigationButtons = [firstButton, prevButton, infoButton, nextButton, lastButton] satisfies ButtonBuilder[]; + + const moderationButtons = [ + new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setLabel("Note") + .setCustomId(serializeCustomId({ action: ModMenuActionType.NOTE, target: interaction.targetId })), + new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setLabel("Warn") + .setCustomId(serializeCustomId({ action: ModMenuActionType.WARN, target: interaction.targetId })), + new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setLabel("Clean") + .setCustomId(serializeCustomId({ action: ModMenuActionType.CLEAN, target: interaction.targetId })), + new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setLabel("Mute") + .setCustomId(serializeCustomId({ action: ModMenuActionType.MUTE, target: interaction.targetId })), + new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setLabel("Ban") + .setCustomId(serializeCustomId({ action: ModMenuActionType.BAN, target: interaction.targetId })), + ] satisfies ButtonBuilder[]; + + const navigationRow = new ActionRowBuilder().addComponents(navigationButtons); + const moderationRow = new ActionRowBuilder().addComponents(moderationButtons); + + let page = 1; + const currentPage = await interaction.editReply({ + embeds: [await loadPage(page)], + components: [navigationRow, moderationRow], + }); + + const collector = await currentPage.createMessageComponentCollector({ + time: MOD_MENU_TIMEOUT, + }); + + collector.on("collect", async (i) => { + const opts = deserializeCustomId(i.customId); + if (opts.action == ModMenuActionType.PAGE) { + await i.deferUpdate(); + } + + // Update displayed embed if any navigation buttons were used + if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.INFO && infoEmbed != null) { + infoButton + .setLabel("Cases") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.CASES })); + firstButton.setDisabled(true); + prevButton.setDisabled(true); + nextButton.setDisabled(true); + lastButton.setDisabled(true); + + await i.editReply({ + embeds: [infoEmbed], + components: [navigationRow, moderationRow], + }); + } else if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.CASES) { + infoButton + .setLabel("Info") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO })); + updateNavButtonState(firstButton, prevButton, nextButton, lastButton, page, totalPages); + + await i.editReply({ + embeds: [await loadPage(page)], + components: [navigationRow, moderationRow], + }); + } else if (opts.action == ModMenuActionType.PAGE) { + let pageDelta = 0; + switch (opts.target) { + case ModMenuNavigationType.PREV: + pageDelta = -1; + break; + case ModMenuNavigationType.NEXT: + pageDelta = 1; + break; + } + + let newPage = 1; + if (opts.target == ModMenuNavigationType.PREV || opts.target == ModMenuNavigationType.NEXT) { + newPage = Math.max(Math.min(page + pageDelta, totalPages), 1); + } else if (opts.target == ModMenuNavigationType.FIRST) { + newPage = 1; + } else if (opts.target == ModMenuNavigationType.LAST) { + newPage = totalPages; + } + + if (newPage != page) { + updateNavButtonState(firstButton, prevButton, nextButton, lastButton, newPage, totalPages); + + await i.editReply({ + embeds: [await loadPage(newPage)], + components: [navigationRow, moderationRow], + }); + + page = newPage; + } + } else if (opts.action == ModMenuActionType.NOTE) { + await launchNoteActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.WARN) { + await launchWarnActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.CLEAN) { + await launchCleanActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.MUTE) { + await launchMuteActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.BAN) { + await launchBanActionModal(pluginData, i as ButtonInteraction, opts.target); + } + + collector.resetTimer(); + }); + + // Remove components on timeout. + collector.on("end", async (_, reason) => { + if (reason !== "messageDelete") { + interaction.editReply({ + components: [], + }); + } + }); +} + +function serializeCustomId(opts: ModMenuActionOpts) { + return `${opts.action}:${opts.target}`; +} + +function deserializeCustomId(customId: string): ModMenuActionOpts { + const opts: ModMenuActionOpts = { + action: customId.split(":")[0] as ModMenuActionType, + target: customId.split(":")[1], + }; + + return opts; +} + +function updateNavButtonState( + firstButton: ButtonBuilder, + prevButton: ButtonBuilder, + nextButton: ButtonBuilder, + lastButton: ButtonBuilder, + currentPage: number, + totalPages: number, +) { + if (currentPage > 1) { + firstButton.setDisabled(false); + prevButton.setDisabled(false); + } else { + firstButton.setDisabled(true); + prevButton.setDisabled(true); + } + + if (currentPage == totalPages) { + nextButton.setDisabled(true); + lastButton.setDisabled(true); + } else { + nextButton.setDisabled(false); + lastButton.setDisabled(false); + } +} diff --git a/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts b/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts deleted file mode 100644 index 98e6ab1f..00000000 --- a/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { contextMenuEvt } from "../types"; -import { routeContextAction } from "../utils/contextRouter"; - -export const ContextClickedEvt = contextMenuEvt({ - event: "interactionCreate", - - async listener(meta) { - if (!meta.args.interaction.isContextMenuCommand()) return; - const inter = meta.args.interaction; - await routeContextAction(meta.pluginData, inter); - }, -}); diff --git a/backend/src/plugins/ContextMenus/types.ts b/backend/src/plugins/ContextMenus/types.ts index 02c4a29c..b4340b1b 100644 --- a/backend/src/plugins/ContextMenus/types.ts +++ b/backend/src/plugins/ContextMenus/types.ts @@ -1,25 +1,52 @@ +import { APIEmbed, Awaitable } from "discord.js"; import * as t from "io-ts"; -import { BasePluginType, guildPluginEventListener } from "knub"; -import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks"; +import { BasePluginType } from "knub"; +import { GuildCases } from "../../data/GuildCases"; +import { GuildLogs } from "../../data/GuildLogs"; +import { GuildMutes } from "../../data/GuildMutes"; +import { GuildTempbans } from "../../data/GuildTempbans"; +import { tNullable } from "../../utils"; export const ConfigSchema = t.type({ can_use: t.boolean, - user_muteindef: t.boolean, - user_mute1d: t.boolean, - user_mute1h: t.boolean, - user_info: t.boolean, - message_clean10: t.boolean, - message_clean25: t.boolean, - message_clean50: t.boolean, + can_open_mod_menu: t.boolean, + + log_channel: tNullable(t.string), }); export type TConfigSchema = t.TypeOf; export interface ContextMenuPluginType extends BasePluginType { config: TConfigSchema; state: { - contextMenuLinks: GuildContextMenuLinks; + mutes: GuildMutes; + cases: GuildCases; + tempbans: GuildTempbans; + serverLogs: GuildLogs; }; } -export const contextMenuEvt = guildPluginEventListener(); +export const enum ModMenuActionType { + PAGE = "page", + NOTE = "note", + WARN = "warn", + CLEAN = "clean", + MUTE = "mute", + BAN = "ban", +} + +export const enum ModMenuNavigationType { + FIRST = "first", + PREV = "prev", + NEXT = "next", + LAST = "last", + INFO = "info", + CASES = "cases", +} + +export interface ModMenuActionOpts { + action: ModMenuActionType; + target: string; +} + +export type LoadModMenuPageFn = (page: number) => Awaitable; diff --git a/backend/src/plugins/ContextMenus/utils/contextRouter.ts b/backend/src/plugins/ContextMenus/utils/contextRouter.ts deleted file mode 100644 index 18b7b064..00000000 --- a/backend/src/plugins/ContextMenus/utils/contextRouter.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ContextMenuCommandInteraction } from "discord.js"; -import { GuildPluginData } from "knub"; -import { ContextMenuPluginType } from "../types"; -import { hardcodedActions } from "./hardcodedContextOptions"; - -export async function routeContextAction( - pluginData: GuildPluginData, - interaction: ContextMenuCommandInteraction, -) { - const contextLink = await pluginData.state.contextMenuLinks.get(interaction.commandId); - if (!contextLink) return; - hardcodedActions[contextLink.action_name](pluginData, interaction); -} diff --git a/backend/src/plugins/ContextMenus/utils/hardcodedContextOptions.ts b/backend/src/plugins/ContextMenus/utils/hardcodedContextOptions.ts deleted file mode 100644 index 84593ace..00000000 --- a/backend/src/plugins/ContextMenus/utils/hardcodedContextOptions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { cleanAction } from "../actions/clean"; -import { muteAction } from "../actions/mute"; -import { userInfoAction } from "../actions/userInfo"; - -export const hardcodedContext: Record = { - user_muteindef: "Mute Indefinitely", - user_mute1d: "Mute for 1 day", - user_mute1h: "Mute for 1 hour", - user_info: "Get Info", - message_clean10: "Clean 10 messages", - message_clean25: "Clean 25 messages", - message_clean50: "Clean 50 messages", -}; - -export const hardcodedActions = { - user_muteindef: (pluginData, interaction) => muteAction(pluginData, undefined, interaction), - user_mute1d: (pluginData, interaction) => muteAction(pluginData, "1d", interaction), - user_mute1h: (pluginData, interaction) => muteAction(pluginData, "1h", interaction), - user_info: (pluginData, interaction) => userInfoAction(pluginData, interaction), - message_clean10: (pluginData, interaction) => cleanAction(pluginData, 10, interaction), - message_clean25: (pluginData, interaction) => cleanAction(pluginData, 25, interaction), - message_clean50: (pluginData, interaction) => cleanAction(pluginData, 50, interaction), -}; diff --git a/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts b/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts deleted file mode 100644 index 97d73dfb..00000000 --- a/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ApplicationCommandData, ApplicationCommandType } from "discord.js"; -import { GuildPluginData } from "knub"; -import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin"; -import { ContextMenuPluginType } from "../types"; -import { hardcodedContext } from "./hardcodedContextOptions"; - -export async function loadAllCommands(pluginData: GuildPluginData) { - const comms = await pluginData.client.application!.commands; - const cfg = pluginData.config.get(); - const newCommands: ApplicationCommandData[] = []; - const addedNames: string[] = []; - - for (const [name, label] of Object.entries(hardcodedContext)) { - if (!cfg[name]) continue; - - const type = name.startsWith("user") ? ApplicationCommandType.User : ApplicationCommandType.Message; - const data: ApplicationCommandData = { - type, - name: label, - }; - - addedNames.push(name); - newCommands.push(data); - } - - const setCommands = await comms.set(newCommands, pluginData.guild.id).catch((e) => { - pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unable to overwrite context menus: ${e}` }); - return undefined; - }); - if (!setCommands) return; - - const setCommandsArray = [...setCommands.values()]; - await pluginData.state.contextMenuLinks.deleteAll(); - - for (let i = 0; i < setCommandsArray.length; i++) { - const command = setCommandsArray[i]; - pluginData.state.contextMenuLinks.create(command.id, addedNames[i]); - } -} diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index bf83b13e..9b7d3404 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -41,7 +41,12 @@ import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManua import { PostAlertOnMemberJoinEvt } from "./events/PostAlertOnMemberJoinEvt"; import { banUserId } from "./functions/banUserId"; import { clearTempban } from "./functions/clearTempban"; -import { hasMutePermission } from "./functions/hasMutePerm"; +import { + hasBanPermission, + hasMutePermission, + hasNotePermission, + hasWarnPermission, +} from "./functions/hasModActionPerm"; import { kickMember } from "./functions/kickMember"; import { offModActionsEvent } from "./functions/offModActionsEvent"; import { onModActionsEvent } from "./functions/onModActionsEvent"; @@ -158,7 +163,7 @@ export const ModActionsPlugin = zeppelinGuildPlugin()({ public: { warnMember(pluginData) { return (member: GuildMember, reason: string, warnOptions?: WarnOptions) => { - warnMember(pluginData, member, reason, warnOptions); + return warnMember(pluginData, member, reason, warnOptions); }; }, @@ -170,7 +175,7 @@ export const ModActionsPlugin = zeppelinGuildPlugin()({ banUserId(pluginData) { return (userId: string, reason?: string, banOptions?: BanOptions, banTime?: number) => { - banUserId(pluginData, userId, reason, banOptions, banTime); + return banUserId(pluginData, userId, reason, banOptions, banTime); }; }, @@ -180,12 +185,30 @@ export const ModActionsPlugin = zeppelinGuildPlugin()({ }; }, + hasNotePermission(pluginData) { + return (member: GuildMember, channelId: Snowflake) => { + return hasNotePermission(pluginData, member, channelId); + }; + }, + + hasWarnPermission(pluginData) { + return (member: GuildMember, channelId: Snowflake) => { + return hasWarnPermission(pluginData, member, channelId); + }; + }, + hasMutePermission(pluginData) { return (member: GuildMember, channelId: Snowflake) => { return hasMutePermission(pluginData, member, channelId); }; }, + hasBanPermission(pluginData) { + return (member: GuildMember, channelId: Snowflake) => { + return hasBanPermission(pluginData, member, channelId); + }; + }, + on: mapToPublicFn(onModActionsEvent), off: mapToPublicFn(offModActionsEvent), getEventEmitter(pluginData) { diff --git a/backend/src/plugins/ModActions/functions/hasModActionPerm.ts b/backend/src/plugins/ModActions/functions/hasModActionPerm.ts new file mode 100644 index 00000000..6e28768d --- /dev/null +++ b/backend/src/plugins/ModActions/functions/hasModActionPerm.ts @@ -0,0 +1,35 @@ +import { GuildMember, Snowflake } from "discord.js"; +import { GuildPluginData } from "knub"; +import { ModActionsPluginType } from "../types"; + +export async function hasNotePermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId })).can_note; +} + +export async function hasWarnPermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId })).can_warn; +} + +export async function hasMutePermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId })).can_mute; +} + +export async function hasBanPermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId })).can_ban; +} diff --git a/backend/src/plugins/ModActions/functions/hasMutePerm.ts b/backend/src/plugins/ModActions/functions/hasMutePerm.ts deleted file mode 100644 index b26edd4d..00000000 --- a/backend/src/plugins/ModActions/functions/hasMutePerm.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { GuildMember, Snowflake } from "discord.js"; -import { GuildPluginData } from "knub"; -import { ModActionsPluginType } from "../types"; - -export async function hasMutePermission( - pluginData: GuildPluginData, - member: GuildMember, - channelId: Snowflake, -) { - return (await pluginData.config.getMatchingConfig({ member, channelId })).can_mute; -} From 24b11800f550b9b7584ab705603d6c7ca9fc71cd Mon Sep 17 00:00:00 2001 From: Obliie Date: Sat, 15 Jul 2023 22:05:30 +0100 Subject: [PATCH 2/9] fix: plugin dependencies and cleanup --- .../plugins/ContextMenus/ContextMenuPlugin.ts | 6 ++-- .../src/plugins/ContextMenus/actions/ban.ts | 6 +--- .../src/plugins/ContextMenus/actions/clean.ts | 5 +-- .../src/plugins/ContextMenus/actions/mute.ts | 4 --- .../src/plugins/ContextMenus/actions/note.ts | 5 +-- .../src/plugins/ContextMenus/actions/warn.ts | 7 ++-- .../ContextMenus/commands/ModMenuCmd.ts | 33 +++++++------------ backend/src/plugins/ContextMenus/types.ts | 9 ----- 8 files changed, 20 insertions(+), 55 deletions(-) diff --git a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts index dc08389e..b48cc0ea 100644 --- a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts +++ b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts @@ -4,6 +4,7 @@ import { makeIoTsConfigParser } from "../../pluginUtils"; import { trimPluginDescription } from "../../utils"; import { CasesPlugin } from "../Cases/CasesPlugin"; import { LogsPlugin } from "../Logs/LogsPlugin"; +import { ModActionsPlugin } from "../ModActions/ModActionsPlugin"; import { MutesPlugin } from "../Mutes/MutesPlugin"; import { UtilityPlugin } from "../Utility/UtilityPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; @@ -15,8 +16,6 @@ const defaultOptions: PluginOptions = { can_use: false, can_open_mod_menu: false, - - log_channel: null, }, overrides: [ { @@ -41,8 +40,9 @@ export const ContextMenuPlugin = zeppelinGuildPlugin()({ configSchema: ConfigSchema, }, - dependencies: () => [CasesPlugin, MutesPlugin, LogsPlugin, UtilityPlugin], + dependencies: () => [CasesPlugin, MutesPlugin, ModActionsPlugin, LogsPlugin, UtilityPlugin], configParser: makeIoTsConfigParser(ConfigSchema), + defaultOptions, contextMenuCommands: [ModMenuCmd], diff --git a/backend/src/plugins/ContextMenus/actions/ban.ts b/backend/src/plugins/ContextMenus/actions/ban.ts index 38ec91a1..bfe7bc13 100644 --- a/backend/src/plugins/ContextMenus/actions/ban.ts +++ b/backend/src/plugins/ContextMenus/actions/ban.ts @@ -47,7 +47,7 @@ async function banAction( const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; const result = await modactions.banUserId(target, reason, { caseArgs }, durationMs); if (result.status === "failed") { - await interaction.editReply({ content: "ERROR: Failed to ban user", embeds: [], components: [] }); + await interaction.editReply({ content: "Error: Failed to ban user", embeds: [], components: [] }); return; } @@ -66,22 +66,18 @@ export async function launchBanActionModal( target: string, ) { const modal = new ModalBuilder().setCustomId("ban").setTitle("Ban"); - const durationIn = new TextInputBuilder() .setCustomId("duration") .setLabel("Duration (Optional)") .setRequired(false) .setStyle(TextInputStyle.Short); - const reasonIn = new TextInputBuilder() .setCustomId("reason") .setLabel("Reason (Optional)") .setRequired(false) .setStyle(TextInputStyle.Paragraph); - const durationRow = new ActionRowBuilder().addComponents(durationIn); const reasonRow = new ActionRowBuilder().addComponents(reasonIn); - modal.addComponents(durationRow, reasonRow); await interaction.showModal(modal); diff --git a/backend/src/plugins/ContextMenus/actions/clean.ts b/backend/src/plugins/ContextMenus/actions/clean.ts index 6274f230..3d005bfb 100644 --- a/backend/src/plugins/ContextMenus/actions/clean.ts +++ b/backend/src/plugins/ContextMenus/actions/clean.ts @@ -43,11 +43,8 @@ export async function launchCleanActionModal( target: string, ) { const modal = new ModalBuilder().setCustomId("clean").setTitle("Clean"); - const amountIn = new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short); - const amountRow = new ActionRowBuilder().addComponents(amountIn); - modal.addComponents(amountRow); await interaction.showModal(modal); @@ -57,7 +54,7 @@ export async function launchCleanActionModal( const amount = submitted.fields.getTextInputValue("amount"); if (isNaN(Number(amount))) { - interaction.editReply({ content: `ERROR: Amount ${amount} is invalid`, embeds: [], components: [] }); + interaction.editReply({ content: `Error: Amount '${amount}' is invalid`, embeds: [], components: [] }); return; } diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts index a86a5ddd..90a04575 100644 --- a/backend/src/plugins/ContextMenus/actions/mute.ts +++ b/backend/src/plugins/ContextMenus/actions/mute.ts @@ -77,22 +77,18 @@ export async function launchMuteActionModal( target: string, ) { const modal = new ModalBuilder().setCustomId("mute").setTitle("Mute"); - const durationIn = new TextInputBuilder() .setCustomId("duration") .setLabel("Duration (Optional)") .setRequired(false) .setStyle(TextInputStyle.Short); - const reasonIn = new TextInputBuilder() .setCustomId("reason") .setLabel("Reason (Optional)") .setRequired(false) .setStyle(TextInputStyle.Paragraph); - const durationRow = new ActionRowBuilder().addComponents(durationIn); const reasonRow = new ActionRowBuilder().addComponents(reasonIn); - modal.addComponents(durationRow, reasonRow); await interaction.showModal(modal); diff --git a/backend/src/plugins/ContextMenus/actions/note.ts b/backend/src/plugins/ContextMenus/actions/note.ts index 13d855d5..84b0e2bb 100644 --- a/backend/src/plugins/ContextMenus/actions/note.ts +++ b/backend/src/plugins/ContextMenus/actions/note.ts @@ -36,7 +36,7 @@ async function noteAction( const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { - await interaction.editReply({ content: "Cannot mute: insufficient permissions", embeds: [], components: [] }); + await interaction.editReply({ content: "Cannot note: insufficient permissions", embeds: [], components: [] }); return; } @@ -69,11 +69,8 @@ export async function launchNoteActionModal( target: string, ) { const modal = new ModalBuilder().setCustomId("note").setTitle("Note"); - const reasonIn = new TextInputBuilder().setCustomId("reason").setLabel("Note").setStyle(TextInputStyle.Paragraph); - const reasonRow = new ActionRowBuilder().addComponents(reasonIn); - modal.addComponents(reasonRow); await interaction.showModal(modal); diff --git a/backend/src/plugins/ContextMenus/actions/warn.ts b/backend/src/plugins/ContextMenus/actions/warn.ts index bbfa66c5..c6a0c8ca 100644 --- a/backend/src/plugins/ContextMenus/actions/warn.ts +++ b/backend/src/plugins/ContextMenus/actions/warn.ts @@ -34,7 +34,7 @@ async function warnAction( const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { - await interaction.editReply({ content: "Cannot mute: insufficient permissions", embeds: [], components: [] }); + await interaction.editReply({ content: "Cannot warn: insufficient permissions", embeds: [], components: [] }); return; } @@ -44,7 +44,7 @@ async function warnAction( const result = await modactions.warnMember(targetMember, reason, { caseArgs }); if (result.status === "failed") { - await interaction.editReply({ content: "Failed to warn user", embeds: [], components: [] }); + await interaction.editReply({ content: "Error: Failed to warn user", embeds: [], components: [] }); return; } @@ -61,11 +61,8 @@ export async function launchWarnActionModal( target: string, ) { const modal = new ModalBuilder().setCustomId("warn").setTitle("Warn"); - const reasonIn = new TextInputBuilder().setCustomId("reason").setLabel("Reason").setStyle(TextInputStyle.Paragraph); - const reasonRow = new ActionRowBuilder().addComponents(reasonIn); - modal.addComponents(reasonRow); await interaction.showModal(modal); diff --git a/backend/src/plugins/ContextMenus/commands/ModMenuCmd.ts b/backend/src/plugins/ContextMenus/commands/ModMenuCmd.ts index faf9bd2f..1150893c 100644 --- a/backend/src/plugins/ContextMenus/commands/ModMenuCmd.ts +++ b/backend/src/plugins/ContextMenus/commands/ModMenuCmd.ts @@ -84,33 +84,24 @@ export const ModMenuCmd = guildPluginUserContextMenuCommand({ lines.length == 0 ? `${userName}` : `Most recent cases for ${userName} | ${firstCaseNum}-${lastCaseNum} of ${totalCases}`; - const embedFields = - lines.length == 0 - ? [ - { - name: `**No cases found**`, - value: "", - }, - ] - : [ - ...getChunkedEmbedFields( - emptyEmbedValue, - lines.length == 0 ? `No cases found for **${userName}**` : lines.join("\n"), - ), - { - name: emptyEmbedValue, - value: trimLines(` - Use \`${prefix}case \` to see more information about an individual case - `), - }, - ]; const embed = { author: { name: title, icon_url: user instanceof User ? user.displayAvatarURL() : undefined, }, - fields: embedFields, + fields: [ + ...getChunkedEmbedFields( + emptyEmbedValue, + lines.length == 0 ? `No cases found for **${userName}**` : lines.join("\n"), + ), + { + name: emptyEmbedValue, + value: trimLines( + lines.length == 0 ? "" : `Use \`${prefix}case \` to see more information about an individual case`, + ), + }, + ], footer: { text: `Page ${page}/${totalPages}` }, } satisfies APIEmbed; diff --git a/backend/src/plugins/ContextMenus/types.ts b/backend/src/plugins/ContextMenus/types.ts index b4340b1b..d099d843 100644 --- a/backend/src/plugins/ContextMenus/types.ts +++ b/backend/src/plugins/ContextMenus/types.ts @@ -2,27 +2,18 @@ import { APIEmbed, Awaitable } from "discord.js"; import * as t from "io-ts"; import { BasePluginType } from "knub"; import { GuildCases } from "../../data/GuildCases"; -import { GuildLogs } from "../../data/GuildLogs"; -import { GuildMutes } from "../../data/GuildMutes"; -import { GuildTempbans } from "../../data/GuildTempbans"; -import { tNullable } from "../../utils"; export const ConfigSchema = t.type({ can_use: t.boolean, can_open_mod_menu: t.boolean, - - log_channel: tNullable(t.string), }); export type TConfigSchema = t.TypeOf; export interface ContextMenuPluginType extends BasePluginType { config: TConfigSchema; state: { - mutes: GuildMutes; cases: GuildCases; - tempbans: GuildTempbans; - serverLogs: GuildLogs; }; } From 454bec6c9f0ade8e6512382d6fc44f0f3e1f8bd2 Mon Sep 17 00:00:00 2001 From: Obliie Date: Sat, 15 Jul 2023 22:51:41 +0100 Subject: [PATCH 3/9] feat: Add user context menu commands for notes, warns, mutes and bans --- .../plugins/ContextMenus/ContextMenuPlugin.ts | 8 +++- .../src/plugins/ContextMenus/actions/ban.ts | 25 ++++++++----- .../src/plugins/ContextMenus/actions/clean.ts | 2 +- .../src/plugins/ContextMenus/actions/mute.ts | 37 ++++++++++++++----- .../src/plugins/ContextMenus/actions/note.ts | 31 ++++++++++++---- .../src/plugins/ContextMenus/actions/warn.ts | 33 ++++++++++++----- .../ContextMenus/commands/BanUserCtxCmd.ts | 9 +++++ .../{ModMenuCmd.ts => ModMenuUserCtxCmd.ts} | 2 +- .../ContextMenus/commands/MuteUserCtxCmd.ts | 9 +++++ .../ContextMenus/commands/NoteUserCtxCmd.ts | 9 +++++ .../ContextMenus/commands/WarnUserCtxCmd.ts | 9 +++++ 11 files changed, 135 insertions(+), 39 deletions(-) create mode 100644 backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts rename backend/src/plugins/ContextMenus/commands/{ModMenuCmd.ts => ModMenuUserCtxCmd.ts} (99%) create mode 100644 backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts create mode 100644 backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts create mode 100644 backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts diff --git a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts index b48cc0ea..5461409d 100644 --- a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts +++ b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts @@ -8,7 +8,11 @@ import { ModActionsPlugin } from "../ModActions/ModActionsPlugin"; import { MutesPlugin } from "../Mutes/MutesPlugin"; import { UtilityPlugin } from "../Utility/UtilityPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; -import { ModMenuCmd } from "./commands/ModMenuCmd"; +import { BanCmd } from "./commands/BanUserCtxCmd"; +import { ModMenuCmd } from "./commands/ModMenuUserCtxCmd"; +import { MuteCmd } from "./commands/MuteUserCtxCmd"; +import { NoteCmd } from "./commands/NoteUserCtxCmd"; +import { WarnCmd } from "./commands/WarnUserCtxCmd"; import { ConfigSchema, ContextMenuPluginType } from "./types"; const defaultOptions: PluginOptions = { @@ -45,7 +49,7 @@ export const ContextMenuPlugin = zeppelinGuildPlugin()({ defaultOptions, - contextMenuCommands: [ModMenuCmd], + contextMenuCommands: [ModMenuCmd, NoteCmd, WarnCmd, MuteCmd, BanCmd], beforeLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/ContextMenus/actions/ban.ts b/backend/src/plugins/ContextMenus/actions/ban.ts index bfe7bc13..bfc756d2 100644 --- a/backend/src/plugins/ContextMenus/actions/ban.ts +++ b/backend/src/plugins/ContextMenus/actions/ban.ts @@ -1,6 +1,7 @@ import { ActionRowBuilder, ButtonInteraction, + ContextMenuCommandInteraction, ModalBuilder, ModalSubmitInteraction, TextInputBuilder, @@ -12,7 +13,7 @@ import { canActOn } from "src/pluginUtils"; import { ModActionsPlugin } from "src/plugins/ModActions/ModActionsPlugin"; import { convertDelayStringToMS, renderUserUsername } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; -import { MODAL_TIMEOUT } from "../commands/ModMenuCmd"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd"; import { ContextMenuPluginType } from "../types"; async function banAction( @@ -20,8 +21,10 @@ async function banAction( duration: string | undefined, reason: string | undefined, target: string, - interaction: ButtonInteraction, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + submitInteraction: ModalSubmitInteraction, ) { + const interactionToReply = interaction instanceof ButtonInteraction ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -30,13 +33,13 @@ async function banAction( const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasBanPermission(executingMember, interaction.channelId))) { - await interaction.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); + await interactionToReply.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); return; } const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { - await interaction.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); + await interactionToReply.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); return; } @@ -47,7 +50,7 @@ async function banAction( const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; const result = await modactions.banUserId(target, reason, { caseArgs }, durationMs); if (result.status === "failed") { - await interaction.editReply({ content: "Error: Failed to ban user", embeds: [], components: [] }); + await interactionToReply.editReply({ content: "Error: Failed to ban user", embeds: [], components: [] }); return; } @@ -57,12 +60,12 @@ async function banAction( durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" } (Case #${result.case.case_number})${messageResultText}`; - await interaction.editReply({ content: banMessage, embeds: [], components: [] }); + await interactionToReply.editReply({ content: banMessage, embeds: [], components: [] }); } export async function launchBanActionModal( pluginData: GuildPluginData, - interaction: ButtonInteraction, + interaction: ButtonInteraction | ContextMenuCommandInteraction, target: string, ) { const modal = new ModalBuilder().setCustomId("ban").setTitle("Ban"); @@ -83,11 +86,15 @@ export async function launchBanActionModal( await interaction.showModal(modal); const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); if (submitted) { - await submitted.deferUpdate(); + if (interaction instanceof ButtonInteraction) { + await submitted.deferUpdate(); + } else { + await submitted.deferReply({ ephemeral: true }); + } const duration = submitted.fields.getTextInputValue("duration"); const reason = submitted.fields.getTextInputValue("reason"); - await banAction(pluginData, duration, reason, target, interaction); + await banAction(pluginData, duration, reason, target, interaction, submitted); } } diff --git a/backend/src/plugins/ContextMenus/actions/clean.ts b/backend/src/plugins/ContextMenus/actions/clean.ts index 3d005bfb..34ae2d14 100644 --- a/backend/src/plugins/ContextMenus/actions/clean.ts +++ b/backend/src/plugins/ContextMenus/actions/clean.ts @@ -8,7 +8,7 @@ import { } from "discord.js"; import { GuildPluginData } from "knub"; import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin"; -import { MODAL_TIMEOUT } from "../commands/ModMenuCmd"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd"; import { ContextMenuPluginType } from "../types"; export async function cleanAction( diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts index 90a04575..28a988c8 100644 --- a/backend/src/plugins/ContextMenus/actions/mute.ts +++ b/backend/src/plugins/ContextMenus/actions/mute.ts @@ -1,6 +1,7 @@ import { ActionRowBuilder, ButtonInteraction, + ContextMenuCommandInteraction, ModalBuilder, ModalSubmitInteraction, TextInputBuilder, @@ -15,7 +16,7 @@ import { convertDelayStringToMS } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { MutesPlugin } from "../../Mutes/MutesPlugin"; -import { MODAL_TIMEOUT } from "../commands/ModMenuCmd"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd"; import { ContextMenuPluginType } from "../types"; async function muteAction( @@ -23,8 +24,10 @@ async function muteAction( duration: string | undefined, reason: string | undefined, target: string, - interaction: ButtonInteraction, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + submitInteraction: ModalSubmitInteraction, ) { + const interactionToReply = interaction instanceof ButtonInteraction ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -33,13 +36,21 @@ async function muteAction( const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasMutePermission(executingMember, interaction.channelId))) { - await interaction.editReply({ content: "Cannot mute: insufficient permissions", embeds: [], components: [] }); + await interactionToReply.editReply({ + content: "Cannot mute: insufficient permissions", + embeds: [], + components: [], + }); return; } const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { - await interaction.editReply({ content: "Cannot mute: insufficient permissions", embeds: [], components: [] }); + await interactionToReply.editReply({ + content: "Cannot mute: insufficient permissions", + embeds: [], + components: [], + }); return; } @@ -57,9 +68,13 @@ async function muteAction( durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" } (Case #${result.case.case_number})${messageResultText}`; - await interaction.editReply({ content: muteMessage, embeds: [], components: [] }); + await interactionToReply.editReply({ content: muteMessage, embeds: [], components: [] }); } catch (e) { - await interaction.editReply({ content: "Plugin error, please check your BOT_ALERTs", embeds: [], components: [] }); + await interactionToReply.editReply({ + content: "Plugin error, please check your BOT_ALERTs", + embeds: [], + components: [], + }); if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { pluginData.getPlugin(LogsPlugin).logBotAlert({ @@ -73,7 +88,7 @@ async function muteAction( export async function launchMuteActionModal( pluginData: GuildPluginData, - interaction: ButtonInteraction, + interaction: ButtonInteraction | ContextMenuCommandInteraction, target: string, ) { const modal = new ModalBuilder().setCustomId("mute").setTitle("Mute"); @@ -94,11 +109,15 @@ export async function launchMuteActionModal( await interaction.showModal(modal); const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); if (submitted) { - await submitted.deferUpdate(); + if (interaction instanceof ButtonInteraction) { + await submitted.deferUpdate(); + } else { + await submitted.deferReply({ ephemeral: true }); + } const duration = submitted.fields.getTextInputValue("duration"); const reason = submitted.fields.getTextInputValue("reason"); - await muteAction(pluginData, duration, reason, target, interaction); + await muteAction(pluginData, duration, reason, target, interaction, submitted); } } diff --git a/backend/src/plugins/ContextMenus/actions/note.ts b/backend/src/plugins/ContextMenus/actions/note.ts index 84b0e2bb..51a018ca 100644 --- a/backend/src/plugins/ContextMenus/actions/note.ts +++ b/backend/src/plugins/ContextMenus/actions/note.ts @@ -1,6 +1,7 @@ import { ActionRowBuilder, ButtonInteraction, + ContextMenuCommandInteraction, ModalBuilder, ModalSubmitInteraction, TextInputBuilder, @@ -13,15 +14,17 @@ import { CaseTypes } from "../../../data/CaseTypes"; import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; import { renderUserUsername } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; -import { MODAL_TIMEOUT } from "../commands/ModMenuCmd"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd"; import { ContextMenuPluginType } from "../types"; async function noteAction( pluginData: GuildPluginData, reason: string, target: string, - interaction: ButtonInteraction, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + submitInteraction: ModalSubmitInteraction, ) { + const interactionToReply = interaction instanceof ButtonInteraction ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -30,13 +33,21 @@ async function noteAction( const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasNotePermission(executingMember, interaction.channelId))) { - await interaction.editReply({ content: "Cannot note: insufficient permissions", embeds: [], components: [] }); + await interactionToReply.editReply({ + content: "Cannot note: insufficient permissions", + embeds: [], + components: [], + }); return; } const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { - await interaction.editReply({ content: "Cannot note: insufficient permissions", embeds: [], components: [] }); + await interactionToReply.editReply({ + content: "Cannot note: insufficient permissions", + embeds: [], + components: [], + }); return; } @@ -56,7 +67,7 @@ async function noteAction( }); const userName = renderUserUsername(targetMember.user); - await interaction.editReply({ + await interactionToReply.editReply({ content: `Note added on **${userName}** (Case #${createdCase.case_number})`, embeds: [], components: [], @@ -65,7 +76,7 @@ async function noteAction( export async function launchNoteActionModal( pluginData: GuildPluginData, - interaction: ButtonInteraction, + interaction: ButtonInteraction | ContextMenuCommandInteraction, target: string, ) { const modal = new ModalBuilder().setCustomId("note").setTitle("Note"); @@ -76,10 +87,14 @@ export async function launchNoteActionModal( await interaction.showModal(modal); const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); if (submitted) { - await submitted.deferUpdate(); + if (interaction instanceof ButtonInteraction) { + await submitted.deferUpdate(); + } else { + await submitted.deferReply({ ephemeral: true }); + } const reason = submitted.fields.getTextInputValue("reason"); - await noteAction(pluginData, reason, target, interaction); + await noteAction(pluginData, reason, target, interaction, submitted); } } diff --git a/backend/src/plugins/ContextMenus/actions/warn.ts b/backend/src/plugins/ContextMenus/actions/warn.ts index c6a0c8ca..4ec0cf43 100644 --- a/backend/src/plugins/ContextMenus/actions/warn.ts +++ b/backend/src/plugins/ContextMenus/actions/warn.ts @@ -1,6 +1,7 @@ import { ActionRowBuilder, ButtonInteraction, + ContextMenuCommandInteraction, ModalBuilder, ModalSubmitInteraction, TextInputBuilder, @@ -11,15 +12,17 @@ import { canActOn } from "src/pluginUtils"; import { ModActionsPlugin } from "src/plugins/ModActions/ModActionsPlugin"; import { renderUserUsername } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; -import { MODAL_TIMEOUT } from "../commands/ModMenuCmd"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd"; import { ContextMenuPluginType } from "../types"; async function warnAction( pluginData: GuildPluginData, reason: string, target: string, - interaction: ButtonInteraction, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + submitInteraction: ModalSubmitInteraction, ) { + const interactionToReply = interaction instanceof ButtonInteraction ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -28,13 +31,21 @@ async function warnAction( const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasWarnPermission(executingMember, interaction.channelId))) { - await interaction.editReply({ content: "Cannot warn: insufficient permissions", embeds: [], components: [] }); + await interactionToReply.editReply({ + content: "Cannot warn: insufficient permissions", + embeds: [], + components: [], + }); return; } const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { - await interaction.editReply({ content: "Cannot warn: insufficient permissions", embeds: [], components: [] }); + await interactionToReply.editReply({ + content: "Cannot warn: insufficient permissions", + embeds: [], + components: [], + }); return; } @@ -44,7 +55,7 @@ async function warnAction( const result = await modactions.warnMember(targetMember, reason, { caseArgs }); if (result.status === "failed") { - await interaction.editReply({ content: "Error: Failed to warn user", embeds: [], components: [] }); + await interactionToReply.editReply({ content: "Error: Failed to warn user", embeds: [], components: [] }); return; } @@ -52,12 +63,12 @@ async function warnAction( const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; const muteMessage = `Warned **${userName}** (Case #${result.case.case_number})${messageResultText}`; - await interaction.editReply({ content: muteMessage, embeds: [], components: [] }); + await interactionToReply.editReply({ content: muteMessage, embeds: [], components: [] }); } export async function launchWarnActionModal( pluginData: GuildPluginData, - interaction: ButtonInteraction, + interaction: ButtonInteraction | ContextMenuCommandInteraction, target: string, ) { const modal = new ModalBuilder().setCustomId("warn").setTitle("Warn"); @@ -68,10 +79,14 @@ export async function launchWarnActionModal( await interaction.showModal(modal); const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); if (submitted) { - await submitted.deferUpdate(); + if (interaction instanceof ButtonInteraction) { + await submitted.deferUpdate(); + } else { + await submitted.deferReply({ ephemeral: true }); + } const reason = submitted.fields.getTextInputValue("reason"); - await warnAction(pluginData, reason, target, interaction); + await warnAction(pluginData, reason, target, interaction, submitted); } } diff --git a/backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts new file mode 100644 index 00000000..237d81cb --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts @@ -0,0 +1,9 @@ +import { guildPluginUserContextMenuCommand } from "knub"; +import { launchBanActionModal } from "../actions/ban"; + +export const BanCmd = guildPluginUserContextMenuCommand({ + name: "Ban", + async run({ pluginData, interaction }) { + await launchBanActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/ContextMenus/commands/ModMenuCmd.ts b/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts similarity index 99% rename from backend/src/plugins/ContextMenus/commands/ModMenuCmd.ts rename to backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts index 1150893c..e8a51556 100644 --- a/backend/src/plugins/ContextMenus/commands/ModMenuCmd.ts +++ b/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts @@ -9,13 +9,13 @@ import { } from "discord.js"; import { GuildPluginData, guildPluginUserContextMenuCommand } from "knub"; import { Case } from "../../../data/entities/Case"; -import { getUserInfoEmbed } from "../../../plugins/Utility/functions/getUserInfoEmbed"; import { SECONDS, UnknownUser, emptyEmbedValue, renderUserUsername, resolveUser, trimLines } from "../../../utils"; import { asyncMap } from "../../../utils/async"; import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields"; import { getGuildPrefix } from "../../../utils/getGuildPrefix"; import { CasesPlugin } from "../../Cases/CasesPlugin"; import { UtilityPlugin } from "../../Utility/UtilityPlugin"; +import { getUserInfoEmbed } from "../../Utility/functions/getUserInfoEmbed"; import { launchBanActionModal } from "../actions/ban"; import { launchCleanActionModal } from "../actions/clean"; import { launchMuteActionModal } from "../actions/mute"; diff --git a/backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts new file mode 100644 index 00000000..3c060cb3 --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts @@ -0,0 +1,9 @@ +import { guildPluginUserContextMenuCommand } from "knub"; +import { launchMuteActionModal } from "../actions/mute"; + +export const MuteCmd = guildPluginUserContextMenuCommand({ + name: "Mute", + async run({ pluginData, interaction }) { + await launchMuteActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts new file mode 100644 index 00000000..c4f0fa9d --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts @@ -0,0 +1,9 @@ +import { guildPluginUserContextMenuCommand } from "knub"; +import { launchNoteActionModal } from "../actions/note"; + +export const NoteCmd = guildPluginUserContextMenuCommand({ + name: "Note", + async run({ pluginData, interaction }) { + await launchNoteActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts new file mode 100644 index 00000000..3f62196c --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts @@ -0,0 +1,9 @@ +import { guildPluginUserContextMenuCommand } from "knub"; +import { launchWarnActionModal } from "../actions/warn"; + +export const WarnCmd = guildPluginUserContextMenuCommand({ + name: "Warn", + async run({ pluginData, interaction }) { + await launchWarnActionModal(pluginData, interaction, interaction.targetId); + }, +}); From 7f2f2c8f98ddbd34b142b16e64b7971caf0934e2 Mon Sep 17 00:00:00 2001 From: Obliie Date: Sun, 16 Jul 2023 00:12:13 +0100 Subject: [PATCH 4/9] fix: modal id conflicts causing collectors to respond to unrelated submissions --- .../src/plugins/ContextMenus/actions/ban.ts | 32 +++++++++------- .../src/plugins/ContextMenus/actions/clean.ts | 37 +++++++++---------- .../src/plugins/ContextMenus/actions/mute.ts | 32 +++++++++------- .../src/plugins/ContextMenus/actions/note.ts | 30 ++++++++------- .../src/plugins/ContextMenus/actions/warn.ts | 30 ++++++++------- 5 files changed, 87 insertions(+), 74 deletions(-) diff --git a/backend/src/plugins/ContextMenus/actions/ban.ts b/backend/src/plugins/ContextMenus/actions/ban.ts index bfc756d2..5227c793 100644 --- a/backend/src/plugins/ContextMenus/actions/ban.ts +++ b/backend/src/plugins/ContextMenus/actions/ban.ts @@ -11,10 +11,11 @@ import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; import { canActOn } from "src/pluginUtils"; import { ModActionsPlugin } from "src/plugins/ModActions/ModActionsPlugin"; +import { logger } from "../../../logger"; import { convertDelayStringToMS, renderUserUsername } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd"; -import { ContextMenuPluginType } from "../types"; +import { ContextMenuPluginType, ModMenuActionType } from "../types"; async function banAction( pluginData: GuildPluginData, @@ -24,7 +25,7 @@ async function banAction( interaction: ButtonInteraction | ContextMenuCommandInteraction, submitInteraction: ModalSubmitInteraction, ) { - const interactionToReply = interaction instanceof ButtonInteraction ? interaction : submitInteraction; + const interactionToReply = interaction.isButton() ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -68,7 +69,8 @@ export async function launchBanActionModal( interaction: ButtonInteraction | ContextMenuCommandInteraction, target: string, ) { - const modal = new ModalBuilder().setCustomId("ban").setTitle("Ban"); + const modalId = `${ModMenuActionType.WARN}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Ban"); const durationIn = new TextInputBuilder() .setCustomId("duration") .setLabel("Duration (Optional)") @@ -84,17 +86,19 @@ export async function launchBanActionModal( modal.addComponents(durationRow, reasonRow); await interaction.showModal(modal); - const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); - if (submitted) { - if (interaction instanceof ButtonInteraction) { - await submitted.deferUpdate(); - } else { - await submitted.deferReply({ ephemeral: true }); - } + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + if (interaction.isButton()) { + await submitted.deferUpdate(); + } else if (interaction.isContextMenuCommand()) { + await submitted.deferReply({ ephemeral: true }); + } - const duration = submitted.fields.getTextInputValue("duration"); - const reason = submitted.fields.getTextInputValue("reason"); + const duration = submitted.fields.getTextInputValue("duration"); + const reason = submitted.fields.getTextInputValue("reason"); - await banAction(pluginData, duration, reason, target, interaction, submitted); - } + await banAction(pluginData, duration, reason, target, interaction, submitted); + }) + .catch((err) => logger.error(`Ban modal interaction failed: ${err}`)); } diff --git a/backend/src/plugins/ContextMenus/actions/clean.ts b/backend/src/plugins/ContextMenus/actions/clean.ts index 34ae2d14..13795f04 100644 --- a/backend/src/plugins/ContextMenus/actions/clean.ts +++ b/backend/src/plugins/ContextMenus/actions/clean.ts @@ -1,15 +1,9 @@ -import { - ActionRowBuilder, - ButtonInteraction, - ModalBuilder, - ModalSubmitInteraction, - TextInputBuilder, - TextInputStyle, -} from "discord.js"; +import { ActionRowBuilder, ButtonInteraction, ModalBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; import { GuildPluginData } from "knub"; +import { logger } from "../../../logger"; import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin"; import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd"; -import { ContextMenuPluginType } from "../types"; +import { ContextMenuPluginType, ModMenuActionType } from "../types"; export async function cleanAction( pluginData: GuildPluginData, @@ -42,22 +36,25 @@ export async function launchCleanActionModal( interaction: ButtonInteraction, target: string, ) { - const modal = new ModalBuilder().setCustomId("clean").setTitle("Clean"); + const modalId = `${ModMenuActionType.CLEAN}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Clean"); const amountIn = new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short); const amountRow = new ActionRowBuilder().addComponents(amountIn); modal.addComponents(amountRow); await interaction.showModal(modal); - const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); - if (submitted) { - await submitted.deferUpdate(); + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + await submitted.deferUpdate(); - const amount = submitted.fields.getTextInputValue("amount"); - if (isNaN(Number(amount))) { - interaction.editReply({ content: `Error: Amount '${amount}' is invalid`, embeds: [], components: [] }); - return; - } + const amount = submitted.fields.getTextInputValue("amount"); + if (isNaN(Number(amount))) { + interaction.editReply({ content: `Error: Amount '${amount}' is invalid`, embeds: [], components: [] }); + return; + } - await cleanAction(pluginData, Number(amount), target, interaction); - } + await cleanAction(pluginData, Number(amount), target, interaction); + }) + .catch((err) => logger.error(`Clean modal interaction failed: ${err}`)); } diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts index 28a988c8..ad98d544 100644 --- a/backend/src/plugins/ContextMenus/actions/mute.ts +++ b/backend/src/plugins/ContextMenus/actions/mute.ts @@ -12,12 +12,13 @@ import { GuildPluginData } from "knub"; import { canActOn } from "src/pluginUtils"; import { ModActionsPlugin } from "src/plugins/ModActions/ModActionsPlugin"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; +import { logger } from "../../../logger"; import { convertDelayStringToMS } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { MutesPlugin } from "../../Mutes/MutesPlugin"; import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd"; -import { ContextMenuPluginType } from "../types"; +import { ContextMenuPluginType, ModMenuActionType } from "../types"; async function muteAction( pluginData: GuildPluginData, @@ -27,7 +28,7 @@ async function muteAction( interaction: ButtonInteraction | ContextMenuCommandInteraction, submitInteraction: ModalSubmitInteraction, ) { - const interactionToReply = interaction instanceof ButtonInteraction ? interaction : submitInteraction; + const interactionToReply = interaction.isButton() ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -91,7 +92,8 @@ export async function launchMuteActionModal( interaction: ButtonInteraction | ContextMenuCommandInteraction, target: string, ) { - const modal = new ModalBuilder().setCustomId("mute").setTitle("Mute"); + const modalId = `${ModMenuActionType.MUTE}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Mute"); const durationIn = new TextInputBuilder() .setCustomId("duration") .setLabel("Duration (Optional)") @@ -107,17 +109,19 @@ export async function launchMuteActionModal( modal.addComponents(durationRow, reasonRow); await interaction.showModal(modal); - const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); - if (submitted) { - if (interaction instanceof ButtonInteraction) { - await submitted.deferUpdate(); - } else { - await submitted.deferReply({ ephemeral: true }); - } + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + if (interaction.isButton()) { + await submitted.deferUpdate(); + } else if (interaction.isContextMenuCommand()) { + await submitted.deferReply({ ephemeral: true }); + } - const duration = submitted.fields.getTextInputValue("duration"); - const reason = submitted.fields.getTextInputValue("reason"); + const duration = submitted.fields.getTextInputValue("duration"); + const reason = submitted.fields.getTextInputValue("reason"); - await muteAction(pluginData, duration, reason, target, interaction, submitted); - } + await muteAction(pluginData, duration, reason, target, interaction, submitted); + }) + .catch((err) => logger.error(`Mute modal interaction failed: ${err}`)); } diff --git a/backend/src/plugins/ContextMenus/actions/note.ts b/backend/src/plugins/ContextMenus/actions/note.ts index 51a018ca..ecc48d6e 100644 --- a/backend/src/plugins/ContextMenus/actions/note.ts +++ b/backend/src/plugins/ContextMenus/actions/note.ts @@ -11,11 +11,12 @@ import { GuildPluginData } from "knub"; import { canActOn } from "src/pluginUtils"; import { ModActionsPlugin } from "src/plugins/ModActions/ModActionsPlugin"; import { CaseTypes } from "../../../data/CaseTypes"; +import { logger } from "../../../logger"; import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; import { renderUserUsername } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd"; -import { ContextMenuPluginType } from "../types"; +import { ContextMenuPluginType, ModMenuActionType } from "../types"; async function noteAction( pluginData: GuildPluginData, @@ -24,7 +25,7 @@ async function noteAction( interaction: ButtonInteraction | ContextMenuCommandInteraction, submitInteraction: ModalSubmitInteraction, ) { - const interactionToReply = interaction instanceof ButtonInteraction ? interaction : submitInteraction; + const interactionToReply = interaction.isButton() ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -79,22 +80,25 @@ export async function launchNoteActionModal( interaction: ButtonInteraction | ContextMenuCommandInteraction, target: string, ) { - const modal = new ModalBuilder().setCustomId("note").setTitle("Note"); + const modalId = `${ModMenuActionType.NOTE}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Note"); const reasonIn = new TextInputBuilder().setCustomId("reason").setLabel("Note").setStyle(TextInputStyle.Paragraph); const reasonRow = new ActionRowBuilder().addComponents(reasonIn); modal.addComponents(reasonRow); await interaction.showModal(modal); - const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); - if (submitted) { - if (interaction instanceof ButtonInteraction) { - await submitted.deferUpdate(); - } else { - await submitted.deferReply({ ephemeral: true }); - } + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + if (interaction.isButton()) { + await submitted.deferUpdate(); + } else if (interaction.isContextMenuCommand()) { + await submitted.deferReply({ ephemeral: true }); + } - const reason = submitted.fields.getTextInputValue("reason"); + const reason = submitted.fields.getTextInputValue("reason"); - await noteAction(pluginData, reason, target, interaction, submitted); - } + await noteAction(pluginData, reason, target, interaction, submitted); + }) + .catch((err) => logger.error(`Note modal interaction failed: ${err}`)); } diff --git a/backend/src/plugins/ContextMenus/actions/warn.ts b/backend/src/plugins/ContextMenus/actions/warn.ts index 4ec0cf43..9afbf44e 100644 --- a/backend/src/plugins/ContextMenus/actions/warn.ts +++ b/backend/src/plugins/ContextMenus/actions/warn.ts @@ -10,10 +10,11 @@ import { import { GuildPluginData } from "knub"; import { canActOn } from "src/pluginUtils"; import { ModActionsPlugin } from "src/plugins/ModActions/ModActionsPlugin"; +import { logger } from "../../../logger"; import { renderUserUsername } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd"; -import { ContextMenuPluginType } from "../types"; +import { ContextMenuPluginType, ModMenuActionType } from "../types"; async function warnAction( pluginData: GuildPluginData, @@ -22,7 +23,7 @@ async function warnAction( interaction: ButtonInteraction | ContextMenuCommandInteraction, submitInteraction: ModalSubmitInteraction, ) { - const interactionToReply = interaction instanceof ButtonInteraction ? interaction : submitInteraction; + const interactionToReply = interaction.isButton() ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -71,22 +72,25 @@ export async function launchWarnActionModal( interaction: ButtonInteraction | ContextMenuCommandInteraction, target: string, ) { - const modal = new ModalBuilder().setCustomId("warn").setTitle("Warn"); + const modalId = `${ModMenuActionType.WARN}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Warn"); const reasonIn = new TextInputBuilder().setCustomId("reason").setLabel("Reason").setStyle(TextInputStyle.Paragraph); const reasonRow = new ActionRowBuilder().addComponents(reasonIn); modal.addComponents(reasonRow); await interaction.showModal(modal); - const submitted: ModalSubmitInteraction = await interaction.awaitModalSubmit({ time: MODAL_TIMEOUT }); - if (submitted) { - if (interaction instanceof ButtonInteraction) { - await submitted.deferUpdate(); - } else { - await submitted.deferReply({ ephemeral: true }); - } + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + if (interaction.isButton()) { + await submitted.deferUpdate(); + } else if (interaction.isContextMenuCommand()) { + await submitted.deferReply({ ephemeral: true }); + } - const reason = submitted.fields.getTextInputValue("reason"); + const reason = submitted.fields.getTextInputValue("reason"); - await warnAction(pluginData, reason, target, interaction, submitted); - } + await warnAction(pluginData, reason, target, interaction, submitted); + }) + .catch((err) => logger.error(`Mute modal interaction failed: ${err}`)); } From 8fcbd50ea1517c9fe912cbf8d17d5454372b247a Mon Sep 17 00:00:00 2001 From: Obliie Date: Sun, 16 Jul 2023 00:38:35 +0100 Subject: [PATCH 5/9] fix: interaction error handling --- .../src/plugins/ContextMenus/actions/ban.ts | 22 +- .../src/plugins/ContextMenus/actions/clean.ts | 22 +- .../src/plugins/ContextMenus/actions/mute.ts | 46 ++-- .../src/plugins/ContextMenus/actions/note.ts | 42 ++-- .../src/plugins/ContextMenus/actions/warn.ts | 40 ++-- .../commands/ModMenuUserCtxCmd.ts | 200 ++++++++++-------- 6 files changed, 217 insertions(+), 155 deletions(-) diff --git a/backend/src/plugins/ContextMenus/actions/ban.ts b/backend/src/plugins/ContextMenus/actions/ban.ts index 5227c793..0a61c57f 100644 --- a/backend/src/plugins/ContextMenus/actions/ban.ts +++ b/backend/src/plugins/ContextMenus/actions/ban.ts @@ -34,13 +34,17 @@ async function banAction( const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasBanPermission(executingMember, interaction.channelId))) { - await interactionToReply.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); + await interactionToReply + .editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }) + .catch((err) => logger.error(`Ban interaction reply failed: ${err}`)); return; } const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { - await interactionToReply.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); + await interactionToReply + .editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }) + .catch((err) => logger.error(`Ban interaction reply failed: ${err}`)); return; } @@ -51,7 +55,9 @@ async function banAction( const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; const result = await modactions.banUserId(target, reason, { caseArgs }, durationMs); if (result.status === "failed") { - await interactionToReply.editReply({ content: "Error: Failed to ban user", embeds: [], components: [] }); + await interactionToReply + .editReply({ content: "Error: Failed to ban user", embeds: [], components: [] }) + .catch((err) => logger.error(`Ban interaction reply failed: ${err}`)); return; } @@ -61,7 +67,9 @@ async function banAction( durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" } (Case #${result.case.case_number})${messageResultText}`; - await interactionToReply.editReply({ content: banMessage, embeds: [], components: [] }); + await interactionToReply + .editReply({ content: banMessage, embeds: [], components: [] }) + .catch((err) => logger.error(`Ban interaction reply failed: ${err}`)); } export async function launchBanActionModal( @@ -90,9 +98,11 @@ export async function launchBanActionModal( .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) .then(async (submitted) => { if (interaction.isButton()) { - await submitted.deferUpdate(); + await submitted.deferUpdate().catch((err) => logger.error(`Ban interaction defer failed: ${err}`)); } else if (interaction.isContextMenuCommand()) { - await submitted.deferReply({ ephemeral: true }); + await submitted + .deferReply({ ephemeral: true }) + .catch((err) => logger.error(`Ban interaction defer failed: ${err}`)); } const duration = submitted.fields.getTextInputValue("duration"); diff --git a/backend/src/plugins/ContextMenus/actions/clean.ts b/backend/src/plugins/ContextMenus/actions/clean.ts index 13795f04..628451c6 100644 --- a/backend/src/plugins/ContextMenus/actions/clean.ts +++ b/backend/src/plugins/ContextMenus/actions/clean.ts @@ -19,16 +19,20 @@ export async function cleanAction( const utility = pluginData.getPlugin(UtilityPlugin); if (!userCfg.can_use || !(await utility.hasPermission(executingMember, interaction.channelId, "can_clean"))) { - await interaction.editReply({ content: "Cannot clean: insufficient permissions", embeds: [], components: [] }); + 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`, - embeds: [], - components: [], - }); + await interaction + .editReply({ + content: `TODO: Implementation incomplete`, + embeds: [], + components: [], + }) + .catch((err) => logger.error(`Clean interaction reply failed: ${err}`)); } export async function launchCleanActionModal( @@ -46,11 +50,13 @@ export async function launchCleanActionModal( await interaction .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) .then(async (submitted) => { - await submitted.deferUpdate(); + await submitted.deferUpdate().catch((err) => logger.error(`Clean interaction defer failed: ${err}`)); const amount = submitted.fields.getTextInputValue("amount"); if (isNaN(Number(amount))) { - interaction.editReply({ content: `Error: Amount '${amount}' is invalid`, embeds: [], components: [] }); + interaction + .editReply({ content: `Error: Amount '${amount}' is invalid`, embeds: [], components: [] }) + .catch((err) => logger.error(`Clean interaction reply failed: ${err}`)); return; } diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts index ad98d544..b0ea8776 100644 --- a/backend/src/plugins/ContextMenus/actions/mute.ts +++ b/backend/src/plugins/ContextMenus/actions/mute.ts @@ -37,21 +37,25 @@ async function muteAction( const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasMutePermission(executingMember, interaction.channelId))) { - await interactionToReply.editReply({ - content: "Cannot mute: insufficient permissions", - embeds: [], - components: [], - }); + await interactionToReply + .editReply({ + content: "Cannot mute: insufficient permissions", + embeds: [], + components: [], + }) + .catch((err) => logger.error(`Mute interaction reply failed: ${err}`)); return; } const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { - await interactionToReply.editReply({ - content: "Cannot mute: insufficient permissions", - embeds: [], - components: [], - }); + await interactionToReply + .editReply({ + content: "Cannot mute: insufficient permissions", + embeds: [], + components: [], + }) + .catch((err) => logger.error(`Mute interaction reply failed: ${err}`)); return; } @@ -69,13 +73,17 @@ async function muteAction( durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" } (Case #${result.case.case_number})${messageResultText}`; - await interactionToReply.editReply({ content: muteMessage, embeds: [], components: [] }); + await interactionToReply + .editReply({ content: muteMessage, embeds: [], components: [] }) + .catch((err) => logger.error(`Mute interaction reply failed: ${err}`)); } catch (e) { - await interactionToReply.editReply({ - content: "Plugin error, please check your BOT_ALERTs", - embeds: [], - components: [], - }); + await interactionToReply + .editReply({ + content: "Plugin error, please check your BOT_ALERTs", + embeds: [], + components: [], + }) + .catch((err) => logger.error(`Mute interaction reply failed: ${err}`)); if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { pluginData.getPlugin(LogsPlugin).logBotAlert({ @@ -113,9 +121,11 @@ export async function launchMuteActionModal( .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) .then(async (submitted) => { if (interaction.isButton()) { - await submitted.deferUpdate(); + await submitted.deferUpdate().catch((err) => logger.error(`Mute interaction defer failed: ${err}`)); } else if (interaction.isContextMenuCommand()) { - await submitted.deferReply({ ephemeral: true }); + await submitted + .deferReply({ ephemeral: true }) + .catch((err) => logger.error(`Mute interaction defer failed: ${err}`)); } const duration = submitted.fields.getTextInputValue("duration"); diff --git a/backend/src/plugins/ContextMenus/actions/note.ts b/backend/src/plugins/ContextMenus/actions/note.ts index ecc48d6e..b6911274 100644 --- a/backend/src/plugins/ContextMenus/actions/note.ts +++ b/backend/src/plugins/ContextMenus/actions/note.ts @@ -34,21 +34,25 @@ async function noteAction( const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasNotePermission(executingMember, interaction.channelId))) { - await interactionToReply.editReply({ - content: "Cannot note: insufficient permissions", - embeds: [], - components: [], - }); + await interactionToReply + .editReply({ + content: "Cannot note: insufficient permissions", + embeds: [], + components: [], + }) + .catch((err) => logger.error(`Note interaction reply failed: ${err}`)); return; } const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { - await interactionToReply.editReply({ - content: "Cannot note: insufficient permissions", - embeds: [], - components: [], - }); + await interactionToReply + .editReply({ + content: "Cannot note: insufficient permissions", + embeds: [], + components: [], + }) + .catch((err) => logger.error(`Note interaction reply failed: ${err}`)); return; } @@ -68,11 +72,13 @@ async function noteAction( }); const userName = renderUserUsername(targetMember.user); - await interactionToReply.editReply({ - content: `Note added on **${userName}** (Case #${createdCase.case_number})`, - embeds: [], - components: [], - }); + await interactionToReply + .editReply({ + content: `Note added on **${userName}** (Case #${createdCase.case_number})`, + embeds: [], + components: [], + }) + .catch((err) => logger.error(`Note interaction reply failed: ${err}`)); } export async function launchNoteActionModal( @@ -91,9 +97,11 @@ export async function launchNoteActionModal( .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) .then(async (submitted) => { if (interaction.isButton()) { - await submitted.deferUpdate(); + await submitted.deferUpdate().catch((err) => logger.error(`Note interaction defer failed: ${err}`)); } else if (interaction.isContextMenuCommand()) { - await submitted.deferReply({ ephemeral: true }); + await submitted + .deferReply({ ephemeral: true }) + .catch((err) => logger.error(`Note interaction defer failed: ${err}`)); } const reason = submitted.fields.getTextInputValue("reason"); diff --git a/backend/src/plugins/ContextMenus/actions/warn.ts b/backend/src/plugins/ContextMenus/actions/warn.ts index 9afbf44e..d8eba234 100644 --- a/backend/src/plugins/ContextMenus/actions/warn.ts +++ b/backend/src/plugins/ContextMenus/actions/warn.ts @@ -32,21 +32,25 @@ async function warnAction( const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasWarnPermission(executingMember, interaction.channelId))) { - await interactionToReply.editReply({ - content: "Cannot warn: insufficient permissions", - embeds: [], - components: [], - }); + await interactionToReply + .editReply({ + content: "Cannot warn: insufficient permissions", + embeds: [], + components: [], + }) + .catch((err) => logger.error(`Warn interaction reply failed: ${err}`)); return; } const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { - await interactionToReply.editReply({ - content: "Cannot warn: insufficient permissions", - embeds: [], - components: [], - }); + await interactionToReply + .editReply({ + content: "Cannot warn: insufficient permissions", + embeds: [], + components: [], + }) + .catch((err) => logger.error(`Warn interaction reply failed: ${err}`)); return; } @@ -56,7 +60,9 @@ async function warnAction( const result = await modactions.warnMember(targetMember, reason, { caseArgs }); if (result.status === "failed") { - await interactionToReply.editReply({ content: "Error: Failed to warn user", embeds: [], components: [] }); + await interactionToReply + .editReply({ content: "Error: Failed to warn user", embeds: [], components: [] }) + .catch((err) => logger.error(`Warn interaction reply failed: ${err}`)); return; } @@ -64,7 +70,9 @@ async function warnAction( const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; const muteMessage = `Warned **${userName}** (Case #${result.case.case_number})${messageResultText}`; - await interactionToReply.editReply({ content: muteMessage, embeds: [], components: [] }); + await interactionToReply + .editReply({ content: muteMessage, embeds: [], components: [] }) + .catch((err) => logger.error(`Warn interaction reply failed: ${err}`)); } export async function launchWarnActionModal( @@ -83,14 +91,16 @@ export async function launchWarnActionModal( .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) .then(async (submitted) => { if (interaction.isButton()) { - await submitted.deferUpdate(); + await submitted.deferUpdate().catch((err) => logger.error(`Warn interaction defer failed: ${err}`)); } else if (interaction.isContextMenuCommand()) { - await submitted.deferReply({ ephemeral: true }); + await submitted + .deferReply({ ephemeral: true }) + .catch((err) => logger.error(`Warn interaction defer failed: ${err}`)); } const reason = submitted.fields.getTextInputValue("reason"); await warnAction(pluginData, reason, target, interaction, submitted); }) - .catch((err) => logger.error(`Mute modal interaction failed: ${err}`)); + .catch((err) => logger.error(`Warn modal interaction failed: ${err}`)); } diff --git a/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts index e8a51556..da8a9014 100644 --- a/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts +++ b/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts @@ -9,6 +9,7 @@ import { } from "discord.js"; import { GuildPluginData, guildPluginUserContextMenuCommand } from "knub"; import { Case } from "../../../data/entities/Case"; +import { logger } from "../../../logger"; import { SECONDS, UnknownUser, emptyEmbedValue, renderUserUsername, resolveUser, trimLines } from "../../../utils"; import { asyncMap } from "../../../utils/async"; import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields"; @@ -36,7 +37,9 @@ const CASES_PER_PAGE = 10; export const ModMenuCmd = guildPluginUserContextMenuCommand({ name: "Mod Menu", async run({ pluginData, interaction }) { - await interaction.deferReply({ ephemeral: true }); + await interaction + .deferReply({ ephemeral: true }) + .catch((err) => logger.error(`Mod menu interaction defer failed: ${err}`)); // Run permission checks for executing user. const executingMember = await pluginData.guild.members.fetch(interaction.user.id); @@ -49,13 +52,17 @@ export const ModMenuCmd = guildPluginUserContextMenuCommand({ !userCfg.can_use || (await !utility.hasPermission(executingMember, interaction.channelId, "can_open_mod_menu")) ) { - await interaction.followUp({ content: "Error: Insufficient Permissions" }); + await interaction + .followUp({ content: "Error: Insufficient Permissions" }) + .catch((err) => logger.error(`Mod menu interaction follow up failed: ${err}`)); return; } const user = await resolveUser(pluginData.client, interaction.targetId); if (!user.id) { - await interaction.followUp("Error: User not found"); + await interaction + .followUp("Error: User not found") + .catch((err) => logger.error(`Mod menu interaction follow up failed: ${err}`)); return; } @@ -120,7 +127,7 @@ async function displayModMenu( infoEmbed: APIEmbed | null, ) { if (interaction.deferred == false) { - await interaction.deferReply(); + await interaction.deferReply().catch((err) => logger.error(`Mod menu interaction defer failed: ${err}`)); } const firstButton = new ButtonBuilder() @@ -177,98 +184,109 @@ async function displayModMenu( const moderationRow = new ActionRowBuilder().addComponents(moderationButtons); let page = 1; - const currentPage = await interaction.editReply({ - embeds: [await loadPage(page)], - components: [navigationRow, moderationRow], - }); - - const collector = await currentPage.createMessageComponentCollector({ - time: MOD_MENU_TIMEOUT, - }); - - collector.on("collect", async (i) => { - const opts = deserializeCustomId(i.customId); - if (opts.action == ModMenuActionType.PAGE) { - await i.deferUpdate(); - } - - // Update displayed embed if any navigation buttons were used - if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.INFO && infoEmbed != null) { - infoButton - .setLabel("Cases") - .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.CASES })); - firstButton.setDisabled(true); - prevButton.setDisabled(true); - nextButton.setDisabled(true); - lastButton.setDisabled(true); - - await i.editReply({ - embeds: [infoEmbed], - components: [navigationRow, moderationRow], + await interaction + .editReply({ + embeds: [await loadPage(page)], + components: [navigationRow, moderationRow], + }) + .then(async (currentPage) => { + const collector = await currentPage.createMessageComponentCollector({ + time: MOD_MENU_TIMEOUT, }); - } else if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.CASES) { - infoButton - .setLabel("Info") - .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO })); - updateNavButtonState(firstButton, prevButton, nextButton, lastButton, page, totalPages); - await i.editReply({ - embeds: [await loadPage(page)], - components: [navigationRow, moderationRow], + collector.on("collect", async (i) => { + const opts = deserializeCustomId(i.customId); + if (opts.action == ModMenuActionType.PAGE) { + await i.deferUpdate().catch((err) => logger.error(`Mod menu defer failed: ${err}`)); + } + + // Update displayed embed if any navigation buttons were used + if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.INFO && infoEmbed != null) { + infoButton + .setLabel("Cases") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.CASES })); + firstButton.setDisabled(true); + prevButton.setDisabled(true); + nextButton.setDisabled(true); + lastButton.setDisabled(true); + + await i + .editReply({ + embeds: [infoEmbed], + components: [navigationRow, moderationRow], + }) + .catch((err) => logger.error(`Mod menu info view failed: ${err}`)); + } else if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.CASES) { + infoButton + .setLabel("Info") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO })); + updateNavButtonState(firstButton, prevButton, nextButton, lastButton, page, totalPages); + + await i + .editReply({ + embeds: [await loadPage(page)], + components: [navigationRow, moderationRow], + }) + .catch((err) => logger.error(`Mod menu cases view failed: ${err}`)); + } else if (opts.action == ModMenuActionType.PAGE) { + let pageDelta = 0; + switch (opts.target) { + case ModMenuNavigationType.PREV: + pageDelta = -1; + break; + case ModMenuNavigationType.NEXT: + pageDelta = 1; + break; + } + + let newPage = 1; + if (opts.target == ModMenuNavigationType.PREV || opts.target == ModMenuNavigationType.NEXT) { + newPage = Math.max(Math.min(page + pageDelta, totalPages), 1); + } else if (opts.target == ModMenuNavigationType.FIRST) { + newPage = 1; + } else if (opts.target == ModMenuNavigationType.LAST) { + newPage = totalPages; + } + + if (newPage != page) { + updateNavButtonState(firstButton, prevButton, nextButton, lastButton, newPage, totalPages); + + await i + .editReply({ + embeds: [await loadPage(newPage)], + components: [navigationRow, moderationRow], + }) + .catch((err) => logger.error(`Mod menu navigation failed: ${err}`)); + + page = newPage; + } + } else if (opts.action == ModMenuActionType.NOTE) { + await launchNoteActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.WARN) { + await launchWarnActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.CLEAN) { + await launchCleanActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.MUTE) { + await launchMuteActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.BAN) { + await launchBanActionModal(pluginData, i as ButtonInteraction, opts.target); + } + + collector.resetTimer(); }); - } else if (opts.action == ModMenuActionType.PAGE) { - let pageDelta = 0; - switch (opts.target) { - case ModMenuNavigationType.PREV: - pageDelta = -1; - break; - case ModMenuNavigationType.NEXT: - pageDelta = 1; - break; - } - let newPage = 1; - if (opts.target == ModMenuNavigationType.PREV || opts.target == ModMenuNavigationType.NEXT) { - newPage = Math.max(Math.min(page + pageDelta, totalPages), 1); - } else if (opts.target == ModMenuNavigationType.FIRST) { - newPage = 1; - } else if (opts.target == ModMenuNavigationType.LAST) { - newPage = totalPages; - } - - if (newPage != page) { - updateNavButtonState(firstButton, prevButton, nextButton, lastButton, newPage, totalPages); - - await i.editReply({ - embeds: [await loadPage(newPage)], - components: [navigationRow, moderationRow], - }); - - page = newPage; - } - } else if (opts.action == ModMenuActionType.NOTE) { - await launchNoteActionModal(pluginData, i as ButtonInteraction, opts.target); - } else if (opts.action == ModMenuActionType.WARN) { - await launchWarnActionModal(pluginData, i as ButtonInteraction, opts.target); - } else if (opts.action == ModMenuActionType.CLEAN) { - await launchCleanActionModal(pluginData, i as ButtonInteraction, opts.target); - } else if (opts.action == ModMenuActionType.MUTE) { - await launchMuteActionModal(pluginData, i as ButtonInteraction, opts.target); - } else if (opts.action == ModMenuActionType.BAN) { - await launchBanActionModal(pluginData, i as ButtonInteraction, opts.target); - } - - collector.resetTimer(); - }); - - // Remove components on timeout. - collector.on("end", async (_, reason) => { - if (reason !== "messageDelete") { - interaction.editReply({ - components: [], + // Remove components on timeout. + collector.on("end", async (_, reason) => { + if (reason !== "messageDelete") { + await interaction + .editReply({ + components: [], + }) + .catch((err) => logger.error(`Mod menu timeout failed: ${err}`)); + } }); - } - }); + }) + .catch((err) => logger.error(`Mod menu setup failed: ${err}`)); } function serializeCustomId(opts: ModMenuActionOpts) { From cdcca8ccbb2f8f51d7d810fbb26ddc0e0fa39863 Mon Sep 17 00:00:00 2001 From: Obliie Date: Sun, 16 Jul 2023 18:46:00 +0100 Subject: [PATCH 6/9] style(ContextMenus): improve ux --- .../ContextMenus/commands/BanUserCtxCmd.ts | 2 + .../commands/ModMenuUserCtxCmd.ts | 49 ++++++++++++------- .../ContextMenus/commands/MuteUserCtxCmd.ts | 2 + .../ContextMenus/commands/NoteUserCtxCmd.ts | 2 + .../ContextMenus/commands/WarnUserCtxCmd.ts | 2 + 5 files changed, 38 insertions(+), 19 deletions(-) diff --git a/backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts index 237d81cb..741c8f74 100644 --- a/backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts +++ b/backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts @@ -1,8 +1,10 @@ +import { PermissionFlagsBits } from "discord.js"; import { guildPluginUserContextMenuCommand } from "knub"; import { launchBanActionModal } from "../actions/ban"; export const BanCmd = guildPluginUserContextMenuCommand({ name: "Ban", + defaultMemberPermissions: PermissionFlagsBits.BanMembers.toString(), async run({ pluginData, interaction }) { await launchBanActionModal(pluginData, interaction, interaction.targetId); }, diff --git a/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts index da8a9014..9cb40f14 100644 --- a/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts +++ b/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts @@ -5,11 +5,14 @@ import { ButtonInteraction, ButtonStyle, ContextMenuCommandInteraction, + GuildMember, + PermissionFlagsBits, User, } from "discord.js"; import { GuildPluginData, guildPluginUserContextMenuCommand } from "knub"; import { Case } from "../../../data/entities/Case"; import { logger } from "../../../logger"; +import { ModActionsPlugin } from "../../../plugins/ModActions/ModActionsPlugin"; import { SECONDS, UnknownUser, emptyEmbedValue, renderUserUsername, resolveUser, trimLines } from "../../../utils"; import { asyncMap } from "../../../utils/async"; import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields"; @@ -18,7 +21,6 @@ import { CasesPlugin } from "../../Cases/CasesPlugin"; import { UtilityPlugin } from "../../Utility/UtilityPlugin"; import { getUserInfoEmbed } from "../../Utility/functions/getUserInfoEmbed"; import { launchBanActionModal } from "../actions/ban"; -import { launchCleanActionModal } from "../actions/clean"; import { launchMuteActionModal } from "../actions/mute"; import { launchNoteActionModal } from "../actions/note"; import { launchWarnActionModal } from "../actions/warn"; @@ -36,6 +38,7 @@ const CASES_PER_PAGE = 10; export const ModMenuCmd = guildPluginUserContextMenuCommand({ name: "Mod Menu", + defaultMemberPermissions: PermissionFlagsBits.ViewAuditLog.toString(), async run({ pluginData, interaction }) { await interaction .deferReply({ ephemeral: true }) @@ -115,6 +118,7 @@ export const ModMenuCmd = guildPluginUserContextMenuCommand({ return embed; }, infoEmbed, + executingMember, ); }, }); @@ -125,58 +129,65 @@ async function displayModMenu( totalPages: number, loadPage: LoadModMenuPageFn, infoEmbed: APIEmbed | null, + executingMember: GuildMember, ) { if (interaction.deferred == false) { await interaction.deferReply().catch((err) => logger.error(`Mod menu interaction defer failed: ${err}`)); } const firstButton = new ButtonBuilder() - .setStyle(ButtonStyle.Primary) - .setLabel("<<") + .setStyle(ButtonStyle.Secondary) + .setEmoji("⏪") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.FIRST })) .setDisabled(true); const prevButton = new ButtonBuilder() - .setStyle(ButtonStyle.Primary) - .setLabel("<") + .setStyle(ButtonStyle.Secondary) + .setEmoji("⬅") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.PREV })) .setDisabled(true); const infoButton = new ButtonBuilder() .setStyle(ButtonStyle.Primary) .setLabel("Info") + .setEmoji("ℹ") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO })) .setDisabled(infoEmbed != null ? false : true); const nextButton = new ButtonBuilder() - .setStyle(ButtonStyle.Primary) - .setLabel(">") + .setStyle(ButtonStyle.Secondary) + .setEmoji("➡") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.NEXT })) .setDisabled(totalPages > 1 ? false : true); const lastButton = new ButtonBuilder() - .setStyle(ButtonStyle.Primary) - .setLabel(">>") + .setStyle(ButtonStyle.Secondary) + .setEmoji("⏩") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.LAST })) .setDisabled(totalPages > 1 ? false : true); const navigationButtons = [firstButton, prevButton, infoButton, nextButton, lastButton] satisfies ButtonBuilder[]; + const modactions = pluginData.getPlugin(ModActionsPlugin); const moderationButtons = [ new ButtonBuilder() - .setStyle(ButtonStyle.Secondary) + .setStyle(ButtonStyle.Primary) .setLabel("Note") + .setEmoji("📝") + .setDisabled(!(await modactions.hasNotePermission(executingMember, interaction.channelId))) .setCustomId(serializeCustomId({ action: ModMenuActionType.NOTE, target: interaction.targetId })), new ButtonBuilder() - .setStyle(ButtonStyle.Secondary) + .setStyle(ButtonStyle.Primary) .setLabel("Warn") + .setEmoji("⚠️") + .setDisabled(!(await modactions.hasWarnPermission(executingMember, interaction.channelId))) .setCustomId(serializeCustomId({ action: ModMenuActionType.WARN, target: interaction.targetId })), new ButtonBuilder() - .setStyle(ButtonStyle.Secondary) - .setLabel("Clean") - .setCustomId(serializeCustomId({ action: ModMenuActionType.CLEAN, target: interaction.targetId })), - new ButtonBuilder() - .setStyle(ButtonStyle.Secondary) + .setStyle(ButtonStyle.Primary) .setLabel("Mute") + .setEmoji("🔇") + .setDisabled(!(await modactions.hasMutePermission(executingMember, interaction.channelId))) .setCustomId(serializeCustomId({ action: ModMenuActionType.MUTE, target: interaction.targetId })), new ButtonBuilder() - .setStyle(ButtonStyle.Secondary) + .setStyle(ButtonStyle.Primary) .setLabel("Ban") + .setEmoji("🚫") + .setDisabled(!(await modactions.hasBanPermission(executingMember, interaction.channelId))) .setCustomId(serializeCustomId({ action: ModMenuActionType.BAN, target: interaction.targetId })), ] satisfies ButtonBuilder[]; @@ -204,6 +215,7 @@ async function displayModMenu( if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.INFO && infoEmbed != null) { infoButton .setLabel("Cases") + .setEmoji("📋") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.CASES })); firstButton.setDisabled(true); prevButton.setDisabled(true); @@ -219,6 +231,7 @@ async function displayModMenu( } else if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.CASES) { infoButton .setLabel("Info") + .setEmoji("ℹ") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO })); updateNavButtonState(firstButton, prevButton, nextButton, lastButton, page, totalPages); @@ -264,8 +277,6 @@ async function displayModMenu( await launchNoteActionModal(pluginData, i as ButtonInteraction, opts.target); } else if (opts.action == ModMenuActionType.WARN) { await launchWarnActionModal(pluginData, i as ButtonInteraction, opts.target); - } else if (opts.action == ModMenuActionType.CLEAN) { - await launchCleanActionModal(pluginData, i as ButtonInteraction, opts.target); } else if (opts.action == ModMenuActionType.MUTE) { await launchMuteActionModal(pluginData, i as ButtonInteraction, opts.target); } else if (opts.action == ModMenuActionType.BAN) { diff --git a/backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts index 3c060cb3..55123597 100644 --- a/backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts +++ b/backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts @@ -1,8 +1,10 @@ +import { PermissionFlagsBits } from "discord.js"; import { guildPluginUserContextMenuCommand } from "knub"; import { launchMuteActionModal } from "../actions/mute"; export const MuteCmd = guildPluginUserContextMenuCommand({ name: "Mute", + defaultMemberPermissions: PermissionFlagsBits.ModerateMembers.toString(), async run({ pluginData, interaction }) { await launchMuteActionModal(pluginData, interaction, interaction.targetId); }, diff --git a/backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts index c4f0fa9d..d1163383 100644 --- a/backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts +++ b/backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts @@ -1,8 +1,10 @@ +import { PermissionFlagsBits } from "discord.js"; import { guildPluginUserContextMenuCommand } from "knub"; import { launchNoteActionModal } from "../actions/note"; export const NoteCmd = guildPluginUserContextMenuCommand({ name: "Note", + defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(), async run({ pluginData, interaction }) { await launchNoteActionModal(pluginData, interaction, interaction.targetId); }, diff --git a/backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts index 3f62196c..b3e6a545 100644 --- a/backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts +++ b/backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts @@ -1,8 +1,10 @@ +import { PermissionFlagsBits } from "discord.js"; import { guildPluginUserContextMenuCommand } from "knub"; import { launchWarnActionModal } from "../actions/warn"; export const WarnCmd = guildPluginUserContextMenuCommand({ name: "Warn", + defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(), async run({ pluginData, interaction }) { await launchWarnActionModal(pluginData, interaction, interaction.targetId); }, From 26bf9363f91222ef094f75ef2d9f3d4dfe16705b Mon Sep 17 00:00:00 2001 From: Obliie Date: Sun, 16 Jul 2023 19:07:03 +0100 Subject: [PATCH 7/9] fix(ContextMenus): correct ban modal custom id --- backend/src/plugins/ContextMenus/actions/ban.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/plugins/ContextMenus/actions/ban.ts b/backend/src/plugins/ContextMenus/actions/ban.ts index 0a61c57f..259622a6 100644 --- a/backend/src/plugins/ContextMenus/actions/ban.ts +++ b/backend/src/plugins/ContextMenus/actions/ban.ts @@ -77,7 +77,7 @@ export async function launchBanActionModal( interaction: ButtonInteraction | ContextMenuCommandInteraction, target: string, ) { - const modalId = `${ModMenuActionType.WARN}:${interaction.id}`; + const modalId = `${ModMenuActionType.BAN}:${interaction.id}`; const modal = new ModalBuilder().setCustomId(modalId).setTitle("Ban"); const durationIn = new TextInputBuilder() .setCustomId("duration") From 6689f91a6a14e0a72889a75fe0af3c170b4a9628 Mon Sep 17 00:00:00 2001 From: Obliie Date: Sun, 16 Jul 2023 19:48:53 +0100 Subject: [PATCH 8/9] feat(ContextMenus): add new field to modals for case evidence --- .../src/plugins/ContextMenus/actions/ban.ts | 17 +++++++++-- .../src/plugins/ContextMenus/actions/mute.ts | 17 +++++++++-- .../plugins/ContextMenus/actions/update.ts | 28 +++++++++++++++++++ .../src/plugins/ContextMenus/actions/warn.ts | 17 +++++++++-- 4 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 backend/src/plugins/ContextMenus/actions/update.ts diff --git a/backend/src/plugins/ContextMenus/actions/ban.ts b/backend/src/plugins/ContextMenus/actions/ban.ts index 259622a6..f2b391cc 100644 --- a/backend/src/plugins/ContextMenus/actions/ban.ts +++ b/backend/src/plugins/ContextMenus/actions/ban.ts @@ -16,11 +16,13 @@ import { convertDelayStringToMS, renderUserUsername } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd"; import { ContextMenuPluginType, ModMenuActionType } from "../types"; +import { updateAction } from "./update"; async function banAction( pluginData: GuildPluginData, duration: string | undefined, reason: string | undefined, + evidence: string | undefined, target: string, interaction: ButtonInteraction | ContextMenuCommandInteraction, submitInteraction: ModalSubmitInteraction, @@ -67,6 +69,10 @@ async function banAction( durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" } (Case #${result.case.case_number})${messageResultText}`; + if (evidence) { + await updateAction(pluginData, executingMember, result.case, evidence); + } + await interactionToReply .editReply({ content: banMessage, embeds: [], components: [] }) .catch((err) => logger.error(`Ban interaction reply failed: ${err}`)); @@ -89,9 +95,15 @@ export async function launchBanActionModal( .setLabel("Reason (Optional)") .setRequired(false) .setStyle(TextInputStyle.Paragraph); + const evidenceIn = new TextInputBuilder() + .setCustomId("evidence") + .setLabel("Evidence (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); const durationRow = new ActionRowBuilder().addComponents(durationIn); const reasonRow = new ActionRowBuilder().addComponents(reasonIn); - modal.addComponents(durationRow, reasonRow); + const evidenceRow = new ActionRowBuilder().addComponents(evidenceIn); + modal.addComponents(durationRow, reasonRow, evidenceRow); await interaction.showModal(modal); await interaction @@ -107,8 +119,9 @@ export async function launchBanActionModal( const duration = submitted.fields.getTextInputValue("duration"); const reason = submitted.fields.getTextInputValue("reason"); + const evidence = submitted.fields.getTextInputValue("evidence"); - await banAction(pluginData, duration, reason, target, interaction, submitted); + await banAction(pluginData, duration, reason, evidence, target, interaction, submitted); }) .catch((err) => logger.error(`Ban modal interaction failed: ${err}`)); } diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts index b0ea8776..58457cb0 100644 --- a/backend/src/plugins/ContextMenus/actions/mute.ts +++ b/backend/src/plugins/ContextMenus/actions/mute.ts @@ -19,11 +19,13 @@ import { LogsPlugin } from "../../Logs/LogsPlugin"; import { MutesPlugin } from "../../Mutes/MutesPlugin"; import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd"; import { ContextMenuPluginType, ModMenuActionType } from "../types"; +import { updateAction } from "./update"; async function muteAction( pluginData: GuildPluginData, duration: string | undefined, reason: string | undefined, + evidence: string | undefined, target: string, interaction: ButtonInteraction | ContextMenuCommandInteraction, submitInteraction: ModalSubmitInteraction, @@ -73,6 +75,10 @@ async function muteAction( durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" } (Case #${result.case.case_number})${messageResultText}`; + if (evidence) { + await updateAction(pluginData, executingMember, result.case, evidence); + } + await interactionToReply .editReply({ content: muteMessage, embeds: [], components: [] }) .catch((err) => logger.error(`Mute interaction reply failed: ${err}`)); @@ -112,9 +118,15 @@ export async function launchMuteActionModal( .setLabel("Reason (Optional)") .setRequired(false) .setStyle(TextInputStyle.Paragraph); + const evidenceIn = new TextInputBuilder() + .setCustomId("evidence") + .setLabel("Evidence (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); const durationRow = new ActionRowBuilder().addComponents(durationIn); const reasonRow = new ActionRowBuilder().addComponents(reasonIn); - modal.addComponents(durationRow, reasonRow); + const evidenceRow = new ActionRowBuilder().addComponents(evidenceIn); + modal.addComponents(durationRow, reasonRow, evidenceRow); await interaction.showModal(modal); await interaction @@ -130,8 +142,9 @@ export async function launchMuteActionModal( const duration = submitted.fields.getTextInputValue("duration"); const reason = submitted.fields.getTextInputValue("reason"); + const evidence = submitted.fields.getTextInputValue("evidence"); - await muteAction(pluginData, duration, reason, target, interaction, submitted); + await muteAction(pluginData, duration, reason, evidence, target, interaction, submitted); }) .catch((err) => logger.error(`Mute modal interaction failed: ${err}`)); } diff --git a/backend/src/plugins/ContextMenus/actions/update.ts b/backend/src/plugins/ContextMenus/actions/update.ts new file mode 100644 index 00000000..3365f293 --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/update.ts @@ -0,0 +1,28 @@ +import { GuildMember } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { Case } from "../../../data/entities/Case"; +import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; +import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin"; +import { ContextMenuPluginType } from "../types"; + +export async function updateAction( + pluginData: GuildPluginData, + executingMember: GuildMember, + theCase: Case, + value: string, +) { + const casesPlugin = pluginData.getPlugin(CasesPlugin); + await casesPlugin.createCaseNote({ + caseId: theCase.case_number, + modId: executingMember.id, + body: value, + }); + + pluginData.getPlugin(LogsPlugin).logCaseUpdate({ + mod: executingMember.user, + caseNumber: theCase.case_number, + caseType: CaseTypes[theCase.type], + note: value, + }); +} diff --git a/backend/src/plugins/ContextMenus/actions/warn.ts b/backend/src/plugins/ContextMenus/actions/warn.ts index d8eba234..6fbd40f3 100644 --- a/backend/src/plugins/ContextMenus/actions/warn.ts +++ b/backend/src/plugins/ContextMenus/actions/warn.ts @@ -15,10 +15,12 @@ import { renderUserUsername } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd"; import { ContextMenuPluginType, ModMenuActionType } from "../types"; +import { updateAction } from "./update"; async function warnAction( pluginData: GuildPluginData, reason: string, + evidence: string | undefined, target: string, interaction: ButtonInteraction | ContextMenuCommandInteraction, submitInteraction: ModalSubmitInteraction, @@ -70,6 +72,10 @@ async function warnAction( const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; const muteMessage = `Warned **${userName}** (Case #${result.case.case_number})${messageResultText}`; + if (evidence) { + await updateAction(pluginData, executingMember, result.case, evidence); + } + await interactionToReply .editReply({ content: muteMessage, embeds: [], components: [] }) .catch((err) => logger.error(`Warn interaction reply failed: ${err}`)); @@ -83,8 +89,14 @@ export async function launchWarnActionModal( const modalId = `${ModMenuActionType.WARN}:${interaction.id}`; const modal = new ModalBuilder().setCustomId(modalId).setTitle("Warn"); const reasonIn = new TextInputBuilder().setCustomId("reason").setLabel("Reason").setStyle(TextInputStyle.Paragraph); + const evidenceIn = new TextInputBuilder() + .setCustomId("evidence") + .setLabel("Evidence (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); const reasonRow = new ActionRowBuilder().addComponents(reasonIn); - modal.addComponents(reasonRow); + const evidenceRow = new ActionRowBuilder().addComponents(evidenceIn); + modal.addComponents(reasonRow, evidenceRow); await interaction.showModal(modal); await interaction @@ -99,8 +111,9 @@ export async function launchWarnActionModal( } const reason = submitted.fields.getTextInputValue("reason"); + const evidence = submitted.fields.getTextInputValue("evidence"); - await warnAction(pluginData, reason, target, interaction, submitted); + await warnAction(pluginData, reason, evidence, target, interaction, submitted); }) .catch((err) => logger.error(`Warn modal interaction failed: ${err}`)); } From 740aa39cd5119581f8744932ba230d1502d0ccee Mon Sep 17 00:00:00 2001 From: Obliie Date: Sat, 5 Aug 2023 21:31:24 +0100 Subject: [PATCH 9/9] feat: add clean message context menu command --- backend/src/pluginUtils.ts | 43 +++++++++++------ .../plugins/ContextMenus/ContextMenuPlugin.ts | 3 +- .../src/plugins/ContextMenus/actions/clean.ts | 36 +++++++++++---- .../commands/CleanMessageCtxCmd.ts | 11 +++++ .../src/plugins/Utility/commands/CleanCmd.ts | 46 +++++++++++++++---- 5 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 backend/src/plugins/ContextMenus/commands/CleanMessageCtxCmd.ts 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); }