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; -}