diff --git a/backend/src/plugins/Cases/CasesPlugin.ts b/backend/src/plugins/Cases/CasesPlugin.ts index 260ca987..0dcc2152 100644 --- a/backend/src/plugins/Cases/CasesPlugin.ts +++ b/backend/src/plugins/Cases/CasesPlugin.ts @@ -9,6 +9,7 @@ import { Case } from "../../data/entities/Case"; import { postCaseToCaseLogChannel } from "./functions/postToCaseLogChannel"; import { CaseTypes } from "../../data/CaseTypes"; import { getCaseTypeAmountForUserId } from "./functions/getCaseTypeAmountForUserId"; +import { getCaseEmbed } from "./functions/getCaseEmbed"; const defaultOptions = { config: { @@ -45,6 +46,12 @@ export const CasesPlugin = zeppelinPlugin()("cases", { return getCaseTypeAmountForUserId(pluginData, userID, type); }; }, + + getCaseEmbed(pluginData) { + return (caseOrCaseId: Case | number) => { + return getCaseEmbed(pluginData, caseOrCaseId); + }; + }, }, onLoad(pluginData) { diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index 1135abed..fe918678 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -9,6 +9,25 @@ import { UpdateCmd } from "./commands/UpdateCmd"; import { NoteCmd } from "./commands/NoteCmd"; import { WarnCmd } from "./commands/WarnCmd"; import { MuteCmd } from "./commands/MuteCmd"; +import { PostAlertOnMemberJoinEvt } from "./events/PostAlertOnMemberJoinEvt"; +import { ForcemuteCmd } from "./commands/ForcemuteCmd"; +import { UnmuteCmd } from "./commands/UnmuteCmd"; +import { KickCmd } from "./commands/KickCmd"; +import { SoftbanCmd } from "./commands/SoftbanCommand"; +import { BanCmd } from "./commands/BanCmd"; +import { UnbanCmd } from "./commands/UnbanCmd"; +import { ForcebanCmd } from "./commands/ForcebanCmd"; +import { MassbanCmd } from "./commands/MassBanCmd"; +import { AddCaseCmd } from "./commands/AddCaseCmd"; +import { CaseCmd } from "./commands/CaseCmd"; +import { CasesUserCmd } from "./commands/CasesUserCmd"; +import { CasesModCmd } from "./commands/CasesModCmd"; +import { HideCaseCmd } from "./commands/HideCaseCmd"; +import { UnhideCaseCmd } from "./commands/UnhideCaseCmd"; +import { GuildMutes } from "src/data/GuildMutes"; +import { GuildCases } from "src/data/GuildCases"; +import { GuildLogs } from "src/data/GuildLogs"; +import { ForceUnmuteCmd } from "./commands/ForceunmuteCmd"; const defaultOptions = { config: { @@ -71,7 +90,42 @@ export const ModActionsPlugin = zeppelinPlugin()("mod_acti dependencies: [CasesPlugin, MutesPlugin], - events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, CreateKickCaseOnManualKickEvt], + events: [ + CreateBanCaseOnManualBanEvt, + CreateUnbanCaseOnManualUnbanEvt, + CreateKickCaseOnManualKickEvt, + PostAlertOnMemberJoinEvt, + ], - commands: [UpdateCmd, NoteCmd, WarnCmd, MuteCmd], + commands: [ + UpdateCmd, + NoteCmd, + WarnCmd, + MuteCmd, + ForcemuteCmd, + UnmuteCmd, + ForceUnmuteCmd, + KickCmd, + SoftbanCmd, + BanCmd, + UnbanCmd, + ForcebanCmd, + MassbanCmd, + AddCaseCmd, + CaseCmd, + CasesUserCmd, + CasesModCmd, + HideCaseCmd, + UnhideCaseCmd, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.mutes = GuildMutes.getGuildInstance(guild.id); + state.cases = GuildCases.getGuildInstance(guild.id); + state.serverLogs = new GuildLogs(guild.id); + + state.ignoredEvents = []; + }, }); diff --git a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts b/backend/src/plugins/ModActions/commands/AddCaseCmd.ts new file mode 100644 index 00000000..a5bdb40c --- /dev/null +++ b/backend/src/plugins/ModActions/commands/AddCaseCmd.ts @@ -0,0 +1,90 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { canActOn, sendErrorMessage, hasPermission, sendSuccessMessage } from "../../../pluginUtils"; +import { resolveUser, resolveMember, stripObjectToScalars } from "../../../utils"; +import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; +import { CaseTypes } from "src/data/CaseTypes"; +import { CasesPlugin } from "src/plugins/Cases/CasesPlugin"; +import { Case } from "src/data/entities/Case"; +import { LogType } from "src/data/LogType"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const AddCaseCmd = modActionsCommand({ + trigger: "addcase", + permission: "can_addcase", + description: "Add an arbitrary case to the specified user without taking any action", + + signature: [ + { + type: ct.string(), + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`); + + // If the user exists as a guild member, make sure we can act on them first + const member = await resolveMember(pluginData.client, pluginData.guild, user.id); + if (member && !canActOn(pluginData, msg.member, member)) { + sendErrorMessage(pluginData, msg.channel, "Cannot add case on this user: insufficient permissions"); + return; + } + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = msg.member; + if (args.mod) { + if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) { + sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + return; + } + + mod = args.mod; + } + + // Verify the case type is valid + const type: string = args.type[0].toUpperCase() + args.type.slice(1).toLowerCase(); + if (!CaseTypes[type]) { + sendErrorMessage(pluginData, msg.channel, "Cannot add case: invalid case type"); + return; + } + + const reason = formatReasonWithAttachments(args.reason, msg.attachments); + + // Create the case + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const theCase: Case = await casesPlugin.createCase({ + userId: user.id, + modId: mod.id, + type: CaseTypes[type], + reason, + ppId: mod.id !== msg.author.id ? msg.author.id : null, + }); + + if (user) { + sendSuccessMessage( + pluginData, + msg.channel, + `Case #${theCase.case_number} created for **${user.username}#${user.discriminator}**`, + ); + } else { + sendSuccessMessage(pluginData, msg.channel, `Case #${theCase.case_number} created`); + } + + // Log the action + pluginData.state.serverLogs.log(LogType.CASE_CREATE, { + mod: stripObjectToScalars(mod.user), + userId: user.id, + caseNum: theCase.case_number, + caseType: type.toUpperCase(), + reason, + }); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/BanCmd.ts b/backend/src/plugins/ModActions/commands/BanCmd.ts new file mode 100644 index 00000000..64f35309 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/BanCmd.ts @@ -0,0 +1,97 @@ +import { modActionsCommand, IgnoredEventType } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { canActOn, sendErrorMessage, hasPermission, sendSuccessMessage } from "../../../pluginUtils"; +import { resolveUser, resolveMember } from "../../../utils"; +import { isBanned } from "../functions/isBanned"; +import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; +import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; +import { banUserId } from "../functions/banUserId"; +import { ignoreEvent } from "../functions/ignoreEvent"; +import { LogType } from "src/data/LogType"; + +const opts = { + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), + "delete-deays": ct.number({ option: true, shortcut: "d" }), +}; + +export const BanCmd = modActionsCommand({ + trigger: "ban", + permission: "can_ban", + description: "Ban the specified member", + + signature: [ + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`); + + const memberToBan = await resolveMember(pluginData.client, pluginData.guild, user.id); + + if (!memberToBan) { + const banned = await isBanned(pluginData, user.id); + if (banned) { + sendErrorMessage(pluginData, msg.channel, `User is already banned`); + } else { + sendErrorMessage(pluginData, msg.channel, `User not found on the server`); + } + + return; + } + + // Make sure we're allowed to ban this member + if (!canActOn(pluginData, msg.member, memberToBan)) { + sendErrorMessage(pluginData, msg.channel, "Cannot ban: insufficient permissions"); + return; + } + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = msg.member; + if (args.mod) { + if (!hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id })) { + sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + return; + } + + mod = args.mod; + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, e.message); + return; + } + + const deleteMessageDays = args["delete-days"] ?? pluginData.config.getForMessage(msg).ban_delete_message_days; + const reason = formatReasonWithAttachments(args.reason, msg.attachments); + const banResult = await banUserId(pluginData, memberToBan.id, reason, { + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== msg.author.id ? msg.author.id : null, + }, + deleteMessageDays, + }); + + if (banResult.status === "failed") { + sendErrorMessage(pluginData, msg.channel, `Failed to ban member`); + return; + } + + // Confirm the action to the moderator + let response = `Banned **${memberToBan.user.username}#${memberToBan.user.discriminator}** (Case #${banResult.case.case_number})`; + + if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`; + sendSuccessMessage(pluginData, msg.channel, response); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/CaseCmd.ts b/backend/src/plugins/ModActions/commands/CaseCmd.ts new file mode 100644 index 00000000..ddb07ee1 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/CaseCmd.ts @@ -0,0 +1,29 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage } from "../../../pluginUtils"; +import { CasesPlugin } from "src/plugins/Cases/CasesPlugin"; + +export const CaseCmd = modActionsCommand({ + trigger: "case", + permission: "can_view", + description: "Show information about a specific case", + + signature: [ + { + caseNumber: ct.number(), + }, + ], + + async run({ pluginData, message: msg, args }) { + const theCase = await pluginData.state.cases.findByCaseNumber(args.caseNumber); + + if (!theCase) { + sendErrorMessage(pluginData, msg.channel, "Case not found"); + return; + } + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const embed = await casesPlugin.getCaseEmbed(theCase.id); + msg.channel.createMessage(embed); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/CasesModCmd.ts b/backend/src/plugins/ModActions/commands/CasesModCmd.ts new file mode 100644 index 00000000..a18812b5 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/CasesModCmd.ts @@ -0,0 +1,43 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage } from "../../../pluginUtils"; +import { trimLines, createChunkedMessage } from "src/utils"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const CasesModCmd = modActionsCommand({ + trigger: "cases", + permission: "can_view", + description: "Show the most recent 5 cases by the specified -mod", + + signature: [ + { + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const modId = args.mod ? args.mod.id : msg.author.id; + const recentCases = await pluginData.state.cases.with("notes").getRecentByModId(modId, 5); + + const mod = pluginData.client.users.get(modId); + const modName = mod ? `${mod.username}#${mod.discriminator}` : modId; + + if (recentCases.length === 0) { + sendErrorMessage(pluginData, msg.channel, `No cases by **${modName}**`); + } else { + const lines = recentCases.map(c => pluginData.state.cases.getSummaryText(c)); + const finalMessage = trimLines(` + Most recent 5 cases by **${modName}**: + + ${lines.join("\n")} + + Use the \`case \` command to see more info about individual cases + Use the \`cases \` command to see a specific user's cases + `); + createChunkedMessage(msg.channel, finalMessage); + } + }, +}); diff --git a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts new file mode 100644 index 00000000..def88a0b --- /dev/null +++ b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts @@ -0,0 +1,84 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage } from "../../../pluginUtils"; +import { CasesPlugin } from "src/plugins/Cases/CasesPlugin"; +import { UnknownUser, multiSorter, trimLines, createChunkedMessage, resolveUser } from "src/utils"; + +const opts = { + expand: ct.bool({ option: true, isSwitch: true, shortcut: "e" }), + hidden: ct.bool({ option: true, isSwitch: true, shortcut: "h" }), +}; + +export const CasesUserCmd = modActionsCommand({ + trigger: "cases", + permission: "can_view", + description: "Show a list of cases the specified user has", + + signature: [ + { + user: ct.string(), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`); + + const cases = await pluginData.state.cases.with("notes").getByUserId(user.id); + const normalCases = cases.filter(c => !c.is_hidden); + const hiddenCases = cases.filter(c => c.is_hidden); + + const userName = + user instanceof UnknownUser && cases.length + ? cases[cases.length - 1].user_name + : `${user.username}#${user.discriminator}`; + + if (cases.length === 0) { + msg.channel.createMessage(`No cases found for **${userName}**`); + } else { + const casesToDisplay = args.hidden ? cases : normalCases; + + if (args.expand) { + if (casesToDisplay.length > 8) { + msg.channel.createMessage("Too many cases for expanded view. Please use compact view instead."); + return; + } + + // Expanded view (= individual case embeds) + const casesPlugin = pluginData.getPlugin(CasesPlugin); + for (const theCase of casesToDisplay) { + const embed = await casesPlugin.getCaseEmbed(theCase.id); + msg.channel.createMessage(embed); + } + } else { + // Compact view (= regular message with a preview of each case) + const lines = []; + for (const theCase of casesToDisplay) { + theCase.notes.sort(multiSorter(["created_at", "id"])); + const caseSummary = pluginData.state.cases.getSummaryText(theCase); + lines.push(caseSummary); + } + + if (!args.hidden && hiddenCases.length) { + if (hiddenCases.length === 1) { + lines.push(`*+${hiddenCases.length} hidden case, use "-hidden" to show it*`); + } else { + lines.push(`*+${hiddenCases.length} hidden cases, use "-hidden" to show them*`); + } + } + + const finalMessage = trimLines(` + Cases for **${userName}**: + + ${lines.join("\n")} + + Use the \`case \` command to see more info about individual cases + `); + + createChunkedMessage(msg.channel, finalMessage); + } + } + }, +}); diff --git a/backend/src/plugins/ModActions/commands/ForcebanCmd.ts b/backend/src/plugins/ModActions/commands/ForcebanCmd.ts new file mode 100644 index 00000000..d75d158c --- /dev/null +++ b/backend/src/plugins/ModActions/commands/ForcebanCmd.ts @@ -0,0 +1,93 @@ +import { modActionsCommand, IgnoredEventType } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { canActOn, sendErrorMessage, hasPermission, sendSuccessMessage } from "../../../pluginUtils"; +import { resolveUser, resolveMember, stripObjectToScalars } from "../../../utils"; +import { isBanned } from "../functions/isBanned"; +import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; +import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; +import { banUserId } from "../functions/banUserId"; +import { ignoreEvent } from "../functions/ignoreEvent"; +import { LogType } from "src/data/LogType"; +import { CaseTypes } from "src/data/CaseTypes"; +import { CasesPlugin } from "src/plugins/Cases/CasesPlugin"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const ForcebanCmd = modActionsCommand({ + trigger: "forceban", + permission: "can_ban", + description: "Force-ban the specified user, even if they aren't on the server", + + signature: [ + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`); + + // If the user exists as a guild member, make sure we can act on them first + const member = await resolveMember(pluginData.client, pluginData.guild, user.id); + if (member && !canActOn(pluginData, msg.member, member)) { + sendErrorMessage(pluginData, msg.channel, "Cannot forceban this user: insufficient permissions"); + return; + } + + // Make sure the user isn't already banned + const banned = await isBanned(pluginData, user.id); + if (banned) { + sendErrorMessage(pluginData, msg.channel, `User is already banned`); + return; + } + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = msg.member; + if (args.mod) { + if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) { + sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + return; + } + + mod = args.mod; + } + + const reason = formatReasonWithAttachments(args.reason, msg.attachments); + + ignoreEvent(pluginData, IgnoredEventType.Ban, user.id); + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id); + + try { + await pluginData.guild.banMember(user.id, 1); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, "Failed to forceban member"); + return; + } + + // Create a case + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: user.id, + modId: mod.id, + type: CaseTypes.Ban, + reason, + ppId: mod.id !== msg.author.id ? msg.author.id : null, + }); + + // Confirm the action + sendSuccessMessage(pluginData, msg.channel, `Member forcebanned (Case #${createdCase.case_number})`); + + // Log the action + pluginData.state.serverLogs.log(LogType.MEMBER_FORCEBAN, { + mod: stripObjectToScalars(mod.user), + userId: user.id, + reason, + }); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts b/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts new file mode 100644 index 00000000..ad505f93 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts @@ -0,0 +1,48 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { canActOn, sendErrorMessage } from "../../../pluginUtils"; +import { resolveMember, resolveUser } from "../../../utils"; +import { actualMuteUserCmd } from "../functions/actualMuteUserCmd"; + +const opts = { + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), +}; + +export const ForcemuteCmd = modActionsCommand({ + trigger: "forcemute", + permission: "can_mute", + description: "Force-mute the specified user, even if they're not on the server", + + signature: [ + { + user: ct.string(), + time: ct.delay(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`); + + const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); + + // Make sure we're allowed to mute this user + if (memberToMute && !canActOn(pluginData, msg.member, memberToMute)) { + sendErrorMessage(pluginData, msg.channel, "Cannot mute: insufficient permissions"); + return; + } + + actualMuteUserCmd(pluginData, user, msg, args); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts b/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts new file mode 100644 index 00000000..92a3d70d --- /dev/null +++ b/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts @@ -0,0 +1,53 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { canActOn, sendErrorMessage } from "../../../pluginUtils"; +import { resolveUser, resolveMember } from "../../../utils"; +import { actualUnmuteCmd } from "../functions/actualUnmuteUserCmd"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const ForceUnmuteCmd = modActionsCommand({ + trigger: "forceunmute", + permission: "can_mute", + description: "Force-unmute the specified user, even if they're not on the server", + + signature: [ + { + user: ct.string(), + time: ct.delay(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`); + + // Check if they're muted in the first place + if (!(await pluginData.state.mutes.isMuted(user.id))) { + sendErrorMessage(pluginData, msg.channel, "Cannot unmute: member is not muted"); + return; + } + + // Find the server member to unmute + const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id); + + // Make sure we're allowed to unmute this member + if (memberToUnmute && !canActOn(pluginData, msg.member, memberToUnmute)) { + sendErrorMessage(pluginData, msg.channel, "Cannot unmute: insufficient permissions"); + return; + } + + actualUnmuteCmd(pluginData, user, msg, args); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/HideCaseCmd.ts b/backend/src/plugins/ModActions/commands/HideCaseCmd.ts new file mode 100644 index 00000000..095713f2 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/HideCaseCmd.ts @@ -0,0 +1,30 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; + +export const HideCaseCmd = modActionsCommand({ + trigger: ["hide", "hidecase", "hide_case"], + permission: "can_hidecase", + description: "Hide the specified case so it doesn't appear in !cases or !info", + + signature: [ + { + caseNum: ct.number(), + }, + ], + + async run({ pluginData, message: msg, args }) { + const theCase = await pluginData.state.cases.findByCaseNumber(args.caseNum); + if (!theCase) { + sendErrorMessage(pluginData, msg.channel, "Case not found!"); + return; + } + + await pluginData.state.cases.setHidden(theCase.id, true); + sendSuccessMessage( + pluginData, + msg.channel, + `Case #${theCase.case_number} is now hidden! Use \`unhidecase\` to unhide it.`, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/KickCmd.ts b/backend/src/plugins/ModActions/commands/KickCmd.ts new file mode 100644 index 00000000..321277cc --- /dev/null +++ b/backend/src/plugins/ModActions/commands/KickCmd.ts @@ -0,0 +1,35 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { canActOn, sendErrorMessage } from "../../../pluginUtils"; +import { resolveUser, resolveMember } from "../../../utils"; +import { MutesPlugin } from "src/plugins/Mutes/MutesPlugin"; +import { actualUnmuteCmd } from "../functions/actualUnmuteUserCmd"; +import { isBanned } from "../functions/isBanned"; +import { plugin } from "knub"; +import { actualKickMemberCmd } from "../functions/actualKickMemberCmd"; + +const opts = { + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), + clean: ct.bool({ option: true, isSwitch: true }), +}; + +export const KickCmd = modActionsCommand({ + trigger: "kick", + permission: "can_kick", + description: "Kick the specified member", + + signature: [ + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + actualKickMemberCmd(pluginData, msg, args); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/MassBanCmd.ts b/backend/src/plugins/ModActions/commands/MassBanCmd.ts new file mode 100644 index 00000000..26f1d6c1 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/MassBanCmd.ts @@ -0,0 +1,109 @@ +import { modActionsCommand, IgnoredEventType } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { canActOn, sendErrorMessage, hasPermission, sendSuccessMessage } from "../../../pluginUtils"; +import { resolveUser, resolveMember, stripObjectToScalars } from "../../../utils"; +import { isBanned } from "../functions/isBanned"; +import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; +import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; +import { banUserId } from "../functions/banUserId"; +import { CaseTypes } from "src/data/CaseTypes"; +import { TextChannel } from "eris"; +import { waitForReply } from "knub/dist/helpers"; +import { ignoreEvent } from "../functions/ignoreEvent"; +import { CasesPlugin } from "src/plugins/Cases/CasesPlugin"; +import { LogType } from "src/data/LogType"; + +export const MassbanCmd = modActionsCommand({ + trigger: "massban", + permission: "can_massban", + description: "Mass-ban a list of user IDs", + + signature: [ + { + userIds: ct.string({ rest: true }), + }, + ], + + async run({ pluginData, message: msg, args }) { + // Limit to 100 users at once (arbitrary?) + if (args.userIds.length > 100) { + sendErrorMessage(pluginData, msg.channel, `Can only massban max 100 users at once`); + return; + } + + // Ask for ban reason (cleaner this way instead of trying to cram it into the args) + msg.channel.createMessage("Ban reason? `cancel` to cancel"); + const banReasonReply = await waitForReply(pluginData.client, msg.channel as TextChannel, msg.author.id); + if (!banReasonReply || !banReasonReply.content || banReasonReply.content.toLowerCase().trim() === "cancel") { + sendErrorMessage(pluginData, msg.channel, "Cancelled"); + return; + } + + const banReason = formatReasonWithAttachments(banReasonReply.content, msg.attachments); + + // Verify we can act on each of the users specified + for (const userId of args.userIds) { + const member = pluginData.guild.members.get(userId); // TODO: Get members on demand? + if (member && !canActOn(pluginData, msg.member, member)) { + sendErrorMessage(pluginData, msg.channel, "Cannot massban one or more users: insufficient permissions"); + return; + } + } + + // Ignore automatic ban cases and logs for these users + // We'll create our own cases below and post a single "mass banned" log instead + args.userIds.forEach(userId => { + // Use longer timeouts since this can take a while + ignoreEvent(pluginData, IgnoredEventType.Ban, userId, 120 * 1000); + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId, 120 * 1000); + }); + + // Show a loading indicator since this can take a while + const loadingMsg = await msg.channel.createMessage("Banning..."); + + // Ban each user and count failed bans (if any) + const failedBans = []; + const casesPlugin = pluginData.getPlugin(CasesPlugin); + for (const userId of args.userIds) { + try { + await pluginData.guild.banMember(userId, 1); + + await casesPlugin.createCase({ + userId, + modId: msg.author.id, + type: CaseTypes.Ban, + reason: `Mass ban: ${banReason}`, + postInCaseLogOverride: false, + }); + } catch (e) { + failedBans.push(userId); + } + } + + // Clear loading indicator + loadingMsg.delete(); + + const successfulBanCount = args.userIds.length - failedBans.length; + if (successfulBanCount === 0) { + // All bans failed - don't create a log entry and notify the user + sendErrorMessage(pluginData, msg.channel, "All bans failed. Make sure the IDs are valid."); + } else { + // Some or all bans were successful. Create a log entry for the mass ban and notify the user. + pluginData.state.serverLogs.log(LogType.MASSBAN, { + mod: stripObjectToScalars(msg.author), + count: successfulBanCount, + reason: banReason, + }); + + if (failedBans.length) { + sendSuccessMessage( + pluginData, + msg.channel, + `Banned ${successfulBanCount} users, ${failedBans.length} failed: ${failedBans.join(" ")}`, + ); + } else { + sendSuccessMessage(pluginData, msg.channel, `Banned ${successfulBanCount} users successfully`); + } + } + }, +}); diff --git a/backend/src/plugins/ModActions/commands/SoftbanCommand.ts b/backend/src/plugins/ModActions/commands/SoftbanCommand.ts new file mode 100644 index 00000000..3e7910b0 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/SoftbanCommand.ts @@ -0,0 +1,35 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { trimPluginDescription } from "../../../utils"; +import { actualKickMemberCmd } from "../functions/actualKickMemberCmd"; + +const opts = { + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), +}; + +export const SoftbanCmd = modActionsCommand({ + trigger: "softban", + permission: "can_kick", + description: trimPluginDescription(` + "Softban" the specified user by banning and immediately unbanning them. Effectively a kick with message deletions. + This command will be removed in the future, please use kick with the \`- clean\` argument instead + `), + + signature: [ + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + await actualKickMemberCmd(pluginData, msg, { clean: true, ...args }); + await msg.channel.createMessage( + "Softban will be removed in the future - please use the kick command with the `-clean` argument instead!", + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/UnbanCmd.ts b/backend/src/plugins/ModActions/commands/UnbanCmd.ts new file mode 100644 index 00000000..32754c9c --- /dev/null +++ b/backend/src/plugins/ModActions/commands/UnbanCmd.ts @@ -0,0 +1,76 @@ +import { modActionsCommand, IgnoredEventType } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, hasPermission, sendSuccessMessage } from "../../../pluginUtils"; +import { resolveUser, stripObjectToScalars } from "../../../utils"; +import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; +import { LogType } from "src/data/LogType"; +import { ignoreEvent } from "../functions/ignoreEvent"; +import { CaseTypes } from "src/data/CaseTypes"; +import { CasesPlugin } from "src/plugins/Cases/CasesPlugin"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const UnbanCmd = modActionsCommand({ + trigger: "unban", + permission: "can_ban", + description: "Unban the specified member", + + signature: [ + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`); + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = msg.member; + if (args.mod) { + if (!hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id })) { + sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + return; + } + + mod = args.mod; + } + + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, user.id); + + try { + ignoreEvent(pluginData, IgnoredEventType.Unban, user.id); + await pluginData.guild.unbanMember(user.id); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, "Failed to unban member; are you sure they're banned?"); + return; + } + + const reason = formatReasonWithAttachments(args.reason, msg.attachments); + + // Create a case + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: user.id, + modId: mod.id, + type: CaseTypes.Unban, + reason, + ppId: mod.id !== msg.author.id ? msg.author.id : null, + }); + + // Confirm the action + sendSuccessMessage(pluginData, msg.channel, `Member unbanned (Case #${createdCase.case_number})`); + + // Log the action + pluginData.state.serverLogs.log(LogType.MEMBER_UNBAN, { + mod: stripObjectToScalars(mod.user), + userId: user.id, + reason, + }); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/UnhideCaseCmd.ts b/backend/src/plugins/ModActions/commands/UnhideCaseCmd.ts new file mode 100644 index 00000000..adb70b68 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/UnhideCaseCmd.ts @@ -0,0 +1,26 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; + +export const UnhideCaseCmd = modActionsCommand({ + trigger: ["unhide", "unhidecase", "unhide_case"], + permission: "can_hidecase", + description: "Un-hide the specified case, making it appear in !cases and !info again", + + signature: [ + { + caseNum: ct.number(), + }, + ], + + async run({ pluginData, message: msg, args }) { + const theCase = await pluginData.state.cases.findByCaseNumber(args.caseNum); + if (!theCase) { + sendErrorMessage(pluginData, msg.channel, "Case not found!"); + return; + } + + await pluginData.state.cases.setHidden(theCase.id, false); + sendSuccessMessage(pluginData, msg.channel, `Case #${theCase.case_number} is no longer hidden!`); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts b/backend/src/plugins/ModActions/commands/UnmuteCmd.ts new file mode 100644 index 00000000..c8808dec --- /dev/null +++ b/backend/src/plugins/ModActions/commands/UnmuteCmd.ts @@ -0,0 +1,76 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { canActOn, sendErrorMessage } from "../../../pluginUtils"; +import { resolveUser, resolveMember } from "../../../utils"; +import { MutesPlugin } from "src/plugins/Mutes/MutesPlugin"; +import { actualUnmuteCmd } from "../functions/actualUnmuteUserCmd"; +import { isBanned } from "../functions/isBanned"; +import { plugin } from "knub"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const UnmuteCmd = modActionsCommand({ + trigger: "unmute", + permission: "can_mute", + description: "Unmute the specified member", + + signature: [ + { + user: ct.string(), + time: ct.delay(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`); + const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id); + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute); + + // Check if they're muted in the first place + if (!(await pluginData.state.mutes.isMuted(args.user)) && !hasMuteRole) { + sendErrorMessage(pluginData, msg.channel, "Cannot unmute: member is not muted"); + return; + } + + if (!memberToUnmute) { + const banned = await isBanned(pluginData, memberToUnmute.id); + const prefix = pluginData.guildConfig.prefix; + if (banned) { + sendErrorMessage( + pluginData, + msg.channel, + `User is banned. Use \`${prefix}forceunmute\` to unmute them anyway.`, + ); + } else { + sendErrorMessage( + pluginData, + msg.channel, + `User is not on the server. Use \`${prefix}forceunmute\` to unmute them anyway.`, + ); + } + + return; + } + + // Make sure we're allowed to unmute this member + if (memberToUnmute && !canActOn(pluginData, msg.member, memberToUnmute)) { + sendErrorMessage(pluginData, msg.channel, "Cannot unmute: insufficient permissions"); + return; + } + + actualUnmuteCmd(pluginData, user, msg, args); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/UpdateCmd.ts b/backend/src/plugins/ModActions/commands/UpdateCmd.ts index e1211c23..5000d286 100644 --- a/backend/src/plugins/ModActions/commands/UpdateCmd.ts +++ b/backend/src/plugins/ModActions/commands/UpdateCmd.ts @@ -13,10 +13,15 @@ export const UpdateCmd = modActionsCommand({ description: "Update the specified case (or, if case number is omitted, your latest case) by adding more notes/details to it", - signature: { - caseNumber: ct.number(), - note: ct.string({ required: false, catchAll: true }), - }, + signature: [ + { + caseNumber: ct.number(), + note: ct.string({ required: false, catchAll: true }), + }, + { + note: ct.string({ catchAll: true }), + }, + ], async run({ pluginData, message: msg, args }) { let theCase: Case; diff --git a/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts b/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts index a41aa27f..764055f3 100644 --- a/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts +++ b/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts @@ -1,7 +1,7 @@ import { eventListener } from "knub"; import { IgnoredEventType, ModActionsPluginType } from "../types"; import { isEventIgnored } from "../functions/isEventIgnored"; -import { clearIgnoredEvent } from "../functions/clearIgnoredEvents"; +import { clearIgnoredEvents } from "../functions/clearIgnoredEvents"; import { Constants as ErisConstants } from "eris"; import { safeFindRelevantAuditLogEntry } from "../functions/safeFindRelevantAuditLogEntry"; import { CasesPlugin } from "../../Cases/CasesPlugin"; @@ -15,7 +15,7 @@ export const CreateBanCaseOnManualBanEvt = eventListener() "guildBanAdd", async ({ pluginData, args: { guild, user } }) => { if (isEventIgnored(pluginData, IgnoredEventType.Ban, user.id)) { - clearIgnoredEvent(pluginData, IgnoredEventType.Ban, user.id); + clearIgnoredEvents(pluginData, IgnoredEventType.Ban, user.id); return; } diff --git a/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts b/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts index 82618174..6606b708 100644 --- a/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts +++ b/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts @@ -1,7 +1,7 @@ import { eventListener } from "knub"; import { IgnoredEventType, ModActionsPluginType } from "../types"; import { isEventIgnored } from "../functions/isEventIgnored"; -import { clearIgnoredEvent } from "../functions/clearIgnoredEvents"; +import { clearIgnoredEvents } from "../functions/clearIgnoredEvents"; import { Constants as ErisConstants } from "eris"; import { safeFindRelevantAuditLogEntry } from "../functions/safeFindRelevantAuditLogEntry"; import { CasesPlugin } from "../../Cases/CasesPlugin"; @@ -18,7 +18,7 @@ export const CreateKickCaseOnManualKickEvt = eventListener "guildMemberRemove", async ({ pluginData, args: { member } }) => { if (isEventIgnored(pluginData, IgnoredEventType.Kick, member.id)) { - clearIgnoredEvent(pluginData, IgnoredEventType.Kick, member.id); + clearIgnoredEvents(pluginData, IgnoredEventType.Kick, member.id); return; } diff --git a/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts b/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts index 2a1ac3aa..9e9fd7a5 100644 --- a/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts +++ b/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts @@ -1,7 +1,7 @@ import { eventListener } from "knub"; import { IgnoredEventType, ModActionsPluginType } from "../types"; import { isEventIgnored } from "../functions/isEventIgnored"; -import { clearIgnoredEvent } from "../functions/clearIgnoredEvents"; +import { clearIgnoredEvents } from "../functions/clearIgnoredEvents"; import { Constants as ErisConstants } from "eris"; import { safeFindRelevantAuditLogEntry } from "../functions/safeFindRelevantAuditLogEntry"; import { CasesPlugin } from "../../Cases/CasesPlugin"; @@ -15,7 +15,7 @@ export const CreateUnbanCaseOnManualUnbanEvt = eventListener { if (isEventIgnored(pluginData, IgnoredEventType.Unban, user.id)) { - clearIgnoredEvent(pluginData, IgnoredEventType.Unban, user.id); + clearIgnoredEvents(pluginData, IgnoredEventType.Unban, user.id); return; } diff --git a/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts b/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts new file mode 100644 index 00000000..92e50bd5 --- /dev/null +++ b/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts @@ -0,0 +1,26 @@ +import { eventListener } from "knub"; +import { ModActionsPluginType } from "../types"; + +/** + * Show an alert if a member with prior notes joins the server + */ +export const PostAlertOnMemberJoinEvt = eventListener()( + "guildMemberAdd", + async ({ pluginData, args: { guild, member } }) => { + const config = pluginData.config.get(); + + if (!config.alert_on_rejoin) return; + + const alertChannelId = config.alert_channel; + if (!alertChannelId) return; + + const actions = await pluginData.state.cases.getByUserId(member.id); + + if (actions.length) { + const alertChannel: any = pluginData.guild.channels.get(alertChannelId); + alertChannel.send( + `<@!${member.id}> (${member.user.username}#${member.user.discriminator} \`${member.id}\`) joined with ${actions.length} prior record(s)`, + ); + } + }, +); diff --git a/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts b/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts new file mode 100644 index 00000000..afbae794 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts @@ -0,0 +1,107 @@ +import { Member, TextChannel } from "eris"; +import { LogType } from "src/data/LogType"; +import { IgnoredEventType, ModActionsPluginType } from "../types"; +import { errorMessage, resolveUser, resolveMember } from "src/utils"; +import { PluginData } from "knub"; +import { sendErrorMessage, canActOn, sendSuccessMessage } from "src/pluginUtils"; +import { hasPermission } from "knub/dist/helpers"; +import { readContactMethodsFromArgs } from "./readContactMethodsFromArgs"; +import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; +import { kickMember } from "./kickMember"; +import { ignoreEvent } from "./ignoreEvent"; +import { isBanned } from "./isBanned"; + +export async function actualKickMemberCmd( + pluginData: PluginData, + msg, + args: { + user: string; + reason: string; + mod: Member; + notify?: string; + "notify-channel"?: TextChannel; + clean?: boolean; + }, +) { + const user = await resolveUser(pluginData.client, args.user); + if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`); + + const memberToKick = await resolveMember(pluginData.client, pluginData.guild, user.id); + + if (!memberToKick) { + const banned = await isBanned(pluginData, user.id); + if (banned) { + sendErrorMessage(pluginData, msg.channel, `User is banned`); + } else { + sendErrorMessage(pluginData, msg.channel, `User not found on the server`); + } + + return; + } + + // Make sure we're allowed to kick this member + if (!canActOn(pluginData, msg.member, memberToKick)) { + sendErrorMessage(pluginData, msg.channel, "Cannot kick: insufficient permissions"); + return; + } + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = msg.member; + if (args.mod) { + if (!hasPermission(pluginData.config.getForMessage(msg), "can_act_as_other")) { + sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + return; + } + + mod = args.mod; + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, e.message); + return; + } + + const reason = formatReasonWithAttachments(args.reason, msg.attachments); + + const kickResult = await kickMember(pluginData, memberToKick, reason, { + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== msg.author.id ? msg.author.id : null, + }, + }); + + if (args.clean) { + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, memberToKick.id); + ignoreEvent(pluginData, IgnoredEventType.Ban, memberToKick.id); + + try { + await memberToKick.ban(1); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, "Failed to ban the user to clean messages (-clean)"); + } + + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, memberToKick.id); + ignoreEvent(pluginData, IgnoredEventType.Unban, memberToKick.id); + + try { + await pluginData.guild.unbanMember(memberToKick.id); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, "Failed to unban the user after banning them (-clean)"); + } + } + + if (kickResult.status === "failed") { + msg.channel.createMessage(errorMessage(`Failed to kick user`)); + return; + } + + // Confirm the action to the moderator + let response = `Kicked **${memberToKick.user.username}#${memberToKick.user.discriminator}** (Case #${kickResult.case.case_number})`; + + if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`; + sendSuccessMessage(pluginData, msg.channel, response); +} diff --git a/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts b/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts new file mode 100644 index 00000000..85402438 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts @@ -0,0 +1,60 @@ +import { PluginData } from "knub"; +import { ModActionsPluginType } from "../types"; +import { User, Message, Member } from "eris"; +import { UnknownUser, asSingleLine } from "src/utils"; +import { sendErrorMessage, sendSuccessMessage, hasPermission } from "src/pluginUtils"; +import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; +import { MutesPlugin } from "src/plugins/Mutes/MutesPlugin"; +import humanizeDuration from "humanize-duration"; + +export async function actualUnmuteCmd( + pluginData: PluginData, + user: User | UnknownUser, + msg: Message, + args: { time?: number; reason?: string; mod?: Member }, +) { + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = msg.author; + let pp = null; + + if (args.mod) { + if (!hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id })) { + sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + return; + } + + mod = args.mod.user; + pp = msg.author; + } + + const reason = formatReasonWithAttachments(args.reason, msg.attachments); + + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + const result = await mutesPlugin.unmuteUser(user.id, args.time, { + modId: mod.id, + ppId: pp && pp.id, + reason, + }); + + // Confirm the action to the moderator + if (args.time) { + const timeUntilUnmute = args.time && humanizeDuration(args.time); + sendSuccessMessage( + pluginData, + msg.channel, + asSingleLine(` + Unmuting **${user.username}#${user.discriminator}** + in ${timeUntilUnmute} (Case #${result.case.case_number}) + `), + ); + } else { + sendSuccessMessage( + pluginData, + msg.channel, + asSingleLine(` + Unmuted **${user.username}#${user.discriminator}** + (Case #${result.case.case_number}) + `), + ); + } +} diff --git a/backend/src/plugins/ModActions/functions/clearIgnoredEvents.ts b/backend/src/plugins/ModActions/functions/clearIgnoredEvents.ts index 612ae58a..765a4c1e 100644 --- a/backend/src/plugins/ModActions/functions/clearIgnoredEvents.ts +++ b/backend/src/plugins/ModActions/functions/clearIgnoredEvents.ts @@ -1,7 +1,7 @@ import { PluginData } from "knub"; import { IgnoredEventType, ModActionsPluginType } from "../types"; -export function clearIgnoredEvent( +export function clearIgnoredEvents( pluginData: PluginData, type: IgnoredEventType, userId: string, diff --git a/backend/src/plugins/ModActions/functions/ignoreEvent.ts b/backend/src/plugins/ModActions/functions/ignoreEvent.ts index 1617e390..aef0a30e 100644 --- a/backend/src/plugins/ModActions/functions/ignoreEvent.ts +++ b/backend/src/plugins/ModActions/functions/ignoreEvent.ts @@ -1,7 +1,7 @@ import { PluginData } from "knub"; import { IgnoredEventType, ModActionsPluginType } from "../types"; import { SECONDS } from "../../../utils"; -import { clearIgnoredEvent } from "./clearIgnoredEvents"; +import { clearIgnoredEvents } from "./clearIgnoredEvents"; const DEFAULT_TIMEOUT = 15 * SECONDS; @@ -15,6 +15,6 @@ export function ignoreEvent( // Clear after expiry (15sec by default) setTimeout(() => { - clearIgnoredEvent(pluginData, type, userId); + clearIgnoredEvents(pluginData, type, userId); }, timeout); } diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts index 98525b8b..34ef8669 100644 --- a/backend/src/plugins/ModActions/types.ts +++ b/backend/src/plugins/ModActions/types.ts @@ -4,7 +4,6 @@ import { BasePluginType, command } from "knub"; import { GuildMutes } from "../../data/GuildMutes"; import { GuildCases } from "../../data/GuildCases"; import { GuildLogs } from "../../data/GuildLogs"; -import { GuildArchives } from "../../data/GuildArchives"; import { Case } from "../../data/entities/Case"; import { CaseArgs } from "../Cases/types"; import { TextChannel } from "eris"; diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts index 1d1a6d9e..597d450b 100644 --- a/backend/src/plugins/Mutes/MutesPlugin.ts +++ b/backend/src/plugins/Mutes/MutesPlugin.ts @@ -12,6 +12,9 @@ import { ClearActiveMuteOnRoleRemovalEvt } from "./events/ClearActiveMuteOnRoleR import { ClearMutesWithoutRoleCmd } from "./commands/ClearMutesWithoutRoleCmd"; import { ClearMutesCmd } from "./commands/ClearMutesCmd"; import { muteUser } from "./functions/muteUser"; +import { unmuteUser } from "./functions/unmuteUser"; +import { CaseArgs } from "../Cases/types"; +import { Member } from "eris"; const defaultOptions = { config: { @@ -75,6 +78,16 @@ export const MutesPlugin = zeppelinPlugin()("mutes", { return muteUser(pluginData, userId, muteTime, reason, muteOptions); }; }, + unmuteUser(pluginData) { + return (userId: string, unmuteTime: number = null, args: Partial) => { + return unmuteUser(pluginData, userId, unmuteTime, args); + }; + }, + hasMutedRole(pluginData) { + return (member: Member) => { + return member.roles.includes(pluginData.config.get().mute_role); + }; + }, }, onLoad(pluginData) { diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 32825725..00ddac17 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -13,6 +13,7 @@ import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigRelo import { CasesPlugin } from "./Cases/CasesPlugin"; import { MutesPlugin } from "./Mutes/MutesPlugin"; import { TagsPlugin } from "./Tags/TagsPlugin"; +import { ModActionsPlugin } from "./ModActions/ModActionsPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -21,6 +22,7 @@ export const guildPlugins: Array> = [ PersistPlugin, PingableRolesPlugin, MessageSaverPlugin, + ModActionsPlugin, NameHistoryPlugin, RemindersPlugin, TagsPlugin,