diff --git a/backend/package-lock.json b/backend/package-lock.json index 2949ed98..daf05930 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -5520,9 +5520,9 @@ } }, "node_modules/knub": { - "version": "32.0.0-next.16", - "resolved": "https://registry.npmjs.org/knub/-/knub-32.0.0-next.16.tgz", - "integrity": "sha512-lmjbLusvinWCoyo0T3dtWy6PEuqysIcqQvg85W85th59ubHasnTc+KGR+4o6EgLCzuDUtc4dP1XQk7XAIKnkYQ==", + "version": "32.0.0-next.17", + "resolved": "https://registry.npmjs.org/knub/-/knub-32.0.0-next.17.tgz", + "integrity": "sha512-q8PeIJuOYUQySOmVuXF56ykklKRnnJmAMpfSN1+vWCNhdMsDxIPZAkRpvekQD6oBkYoL7WlzEsMoUsECGZp+QQ==", "dependencies": { "discord.js": "^14.11.0", "knub-command-manager": "^9.1.0", @@ -9228,11 +9228,16 @@ } }, "node_modules/ts-essentials": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-9.3.2.tgz", - "integrity": "sha512-JxKJzuWqH1MmH4ZFHtJzGEhkfN3QvVR3C3w+4BIoWeoY68UVVoA2Np/Bca9z0IPSErVCWhv439aT0We4Dks8kQ==", + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-9.4.1.tgz", + "integrity": "sha512-oke0rI2EN9pzHsesdmrOrnqv1eQODmJpd/noJjwj2ZPC3Z4N2wbjrOEqnsEgmvlO2+4fBb0a794DCna2elEVIQ==", "peerDependencies": { "typescript": ">=4.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/ts-mixer": { @@ -9645,19 +9650,6 @@ "node": ">=12" } }, - "node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/uid2": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", diff --git a/backend/src/data/GuildCases.ts b/backend/src/data/GuildCases.ts index eb5ed6b7..4137fbee 100644 --- a/backend/src/data/GuildCases.ts +++ b/backend/src/data/GuildCases.ts @@ -1,4 +1,5 @@ import { In, InsertResult, Repository } from "typeorm"; +import { FindOptionsWhere } from "typeorm/find-options/FindOptionsWhere"; import { Queue } from "../Queue"; import { chunkArray } from "../utils"; import { BaseGuildRepository } from "./BaseGuildRepository"; @@ -73,12 +74,16 @@ export class GuildCases extends BaseGuildRepository { }); } - async getByUserId(userId: string): Promise { + async getByUserId( + userId: string, + filters: Omit, "guild_id" | "user_id"> = {}, + ): Promise { return this.cases.find({ relations: this.getRelations(), where: { guild_id: this.guildId, user_id: userId, + ...filters, }, }); } @@ -98,24 +103,40 @@ export class GuildCases extends BaseGuildRepository { }); } - async getTotalCasesByModId(modId: string): Promise { + async getTotalCasesByModId( + modId: string, + filters: Omit, "guild_id" | "mod_id" | "is_hidden"> = {}, + ): Promise { return this.cases.count({ where: { guild_id: this.guildId, mod_id: modId, is_hidden: false, + ...filters, }, }); } - async getRecentByModId(modId: string, count: number, skip = 0): Promise { + async getRecentByModId( + modId: string, + count: number, + skip = 0, + filters: Omit, "guild_id" | "mod_id"> = {}, + ): Promise { + const where: FindOptionsWhere = { + guild_id: this.guildId, + mod_id: modId, + is_hidden: false, + ...filters, + }; + + if (where.is_hidden === true) { + delete where.is_hidden; + } + return this.cases.find({ relations: this.getRelations(), - where: { - guild_id: this.guildId, - mod_id: modId, - is_hidden: false, - }, + where, skip, take: count, order: { diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts index 7ae53217..9736c9fe 100644 --- a/backend/src/pluginUtils.ts +++ b/backend/src/pluginUtils.ts @@ -11,6 +11,7 @@ import { ModalSubmitInteraction, PermissionsBitField, TextBasedChannel, + User, } from "discord.js"; import * as t from "io-ts"; import { @@ -101,19 +102,32 @@ export function makeIoTsConfigParser>(schema: Schema) }; } -function isContextInteraction( - context: TextBasedChannel | ChatInputCommandInteraction, +export function isContextInteraction( + context: TextBasedChannel | User | ChatInputCommandInteraction, ): context is ChatInputCommandInteraction { return "commandId" in context && !!context.commandId; } +export function sendContextResponse( + context: TextBasedChannel | User | ChatInputCommandInteraction, + response: string | Omit, +): Promise { + if (isContextInteraction(context)) { + const options = { ...(typeof response === "string" ? { content: response } : response), fetchReply: true }; + + return (context.replied ? context.followUp(options) : context.reply(options)) as Promise; + } else { + return context.send(response); + } +} + export async function sendSuccessMessage( pluginData: AnyPluginData, - context: TextBasedChannel | ChatInputCommandInteraction, + context: TextBasedChannel | User | ChatInputCommandInteraction, body: string, allowedMentions?: MessageMentionOptions, responseInteraction?: ModalSubmitInteraction, - ephemeral = false, + ephemeral = true, ): Promise { const emoji = pluginData.fullConfig.success_emoji || undefined; const formattedBody = successMessage(body, emoji); @@ -153,12 +167,12 @@ export async function sendSuccessMessage( logger.error(`Context reply failed: ${err}`); return undefined; - }); + }) as Promise; } export async function sendErrorMessage( pluginData: AnyPluginData, - context: TextBasedChannel | ChatInputCommandInteraction, + context: TextBasedChannel | User | ChatInputCommandInteraction, body: string, allowedMentions?: MessageMentionOptions, responseInteraction?: ModalSubmitInteraction, @@ -201,7 +215,7 @@ export async function sendErrorMessage( logger.error(`Context reply failed: ${err}`); return undefined; - }); + }) as Promise; } export function getBaseUrl(pluginData: AnyPluginData) { diff --git a/backend/src/plugins/Cases/functions/getRecentCasesByMod.ts b/backend/src/plugins/Cases/functions/getRecentCasesByMod.ts index b9988078..e482fbd6 100644 --- a/backend/src/plugins/Cases/functions/getRecentCasesByMod.ts +++ b/backend/src/plugins/Cases/functions/getRecentCasesByMod.ts @@ -1,4 +1,5 @@ import { GuildPluginData } from "knub"; +import { FindOptionsWhere } from "typeorm/find-options/FindOptionsWhere"; import { Case } from "../../../data/entities/Case"; import { CasesPluginType } from "../types"; @@ -7,6 +8,7 @@ export function getRecentCasesByMod( modId: string, count: number, skip = 0, + filters: Omit, "guild_id" | "mod_id" | "is_hidden"> = {}, ): Promise { - return pluginData.state.cases.getRecentByModId(modId, count, skip); + return pluginData.state.cases.getRecentByModId(modId, count, skip, filters); } diff --git a/backend/src/plugins/Cases/functions/getTotalCasesByMod.ts b/backend/src/plugins/Cases/functions/getTotalCasesByMod.ts index 5753f12e..235f2d8a 100644 --- a/backend/src/plugins/Cases/functions/getTotalCasesByMod.ts +++ b/backend/src/plugins/Cases/functions/getTotalCasesByMod.ts @@ -1,6 +1,12 @@ import { GuildPluginData } from "knub"; +import { FindOptionsWhere } from "typeorm/find-options/FindOptionsWhere"; +import { Case } from "../../../data/entities/Case"; import { CasesPluginType } from "../types"; -export function getTotalCasesByMod(pluginData: GuildPluginData, modId: string): Promise { - return pluginData.state.cases.getTotalCasesByModId(modId); +export function getTotalCasesByMod( + pluginData: GuildPluginData, + modId: string, + filters: Omit, "guild_id" | "mod_id" | "is_hidden"> = {}, +): Promise { + return pluginData.state.cases.getTotalCasesByModId(modId, filters); } diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index 57b2709e..d9b08381 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -13,29 +13,47 @@ import { LogsPlugin } from "../Logs/LogsPlugin"; import { MutesPlugin } from "../Mutes/MutesPlugin"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; -import { AddCaseCmd } from "./commands/AddCaseCmd"; -import { BanCmd } from "./commands/BanCmd"; -import { CaseCmd } from "./commands/CaseCmd"; -import { CasesModCmd } from "./commands/CasesModCmd"; -import { CasesUserCmd } from "./commands/CasesUserCmd"; -import { DeleteCaseCmd } from "./commands/DeleteCaseCmd"; -import { ForcebanCmd } from "./commands/ForcebanCmd"; -import { ForcemuteCmd } from "./commands/ForcemuteCmd"; -import { ForceUnmuteCmd } from "./commands/ForceunmuteCmd"; -import { HideCaseCmd } from "./commands/HideCaseCmd"; -import { KickCmd } from "./commands/KickCmd"; -import { MassbanCmd } from "./commands/MassBanCmd"; -import { MassunbanCmd } from "./commands/MassUnbanCmd"; -import { MassmuteCmd } from "./commands/MassmuteCmd"; -import { MuteCmd } from "./commands/MuteCmd"; -import { SoftbanCmd } from "./commands/SoftbanCommand"; -import { UnbanCmd } from "./commands/UnbanCmd"; -import { UnhideCaseCmd } from "./commands/UnhideCaseCmd"; -import { UnmuteCmd } from "./commands/UnmuteCmd"; -import { UpdateCmd } from "./commands/UpdateCmd"; -import { WarnCmd } from "./commands/WarnCmd"; +import { AddCaseMsgCmd } from "./commands/addcase/AddCaseMsgCmd"; +import { AddCaseSlashCmd } from "./commands/addcase/AddCaseSlashCmd"; +import { BanMsgCmd } from "./commands/ban/BanMsgCmd"; +import { BanSlashCmd } from "./commands/ban/BanSlashCmd"; +import { CaseMsgCmd } from "./commands/case/CaseMsgCmd"; +import { CaseSlashCmd } from "./commands/case/CaseSlashCmd"; +import { CasesModMsgCmd } from "./commands/cases/CasesModMsgCmd"; +import { CasesSlashCmd } from "./commands/cases/CasesSlashCmd"; +import { CasesUserMsgCmd } from "./commands/cases/CasesUserMsgCmd"; +import { DeleteCaseMsgCmd } from "./commands/deletecase/DeleteCaseMsgCmd"; +import { DeleteCaseSlashCmd } from "./commands/deletecase/DeleteCaseSlashCmd"; +import { ForceBanMsgCmd } from "./commands/forceban/ForceBanMsgCmd"; +import { ForceBanSlashCmd } from "./commands/forceban/ForceBanSlashCmd"; +import { ForceMuteMsgCmd } from "./commands/forcemute/ForceMuteMsgCmd"; +import { ForceMuteSlashCmd } from "./commands/forcemute/ForceMuteSlashCmd"; +import { ForceUnmuteMsgCmd } from "./commands/forceunmute/ForceUnmuteMsgCmd"; +import { ForceUnmuteSlashCmd } from "./commands/forceunmute/ForceUnmuteSlashCmd"; +import { HideCaseMsgCmd } from "./commands/hidecase/HideCaseMsgCmd"; +import { HideCaseSlashCmd } from "./commands/hidecase/HideCaseSlashCmd"; +import { KickMsgCmd } from "./commands/kick/KickMsgCmd"; +import { KickSlashCmd } from "./commands/kick/KickSlashCmd"; +import { MassBanMsgCmd } from "./commands/massban/MassBanMsgCmd"; +import { MassBanSlashCmd } from "./commands/massban/MassBanSlashCmd"; +import { MassMuteMsgCmd } from "./commands/massmute/MassMuteMsgCmd"; +import { MassMuteSlashSlashCmd } from "./commands/massmute/MassMuteSlashCmd"; +import { MassUnbanMsgCmd } from "./commands/massunban/MassUnbanMsgCmd"; +import { MassUnbanSlashCmd } from "./commands/massunban/MassUnbanSlashCmd"; +import { MuteMsgCmd } from "./commands/mute/MuteMsgCmd"; +import { MuteSlashCmd } from "./commands/mute/MuteSlashCmd"; import { NoteMsgCmd } from "./commands/note/NoteMsgCmd"; import { NoteSlashCmd } from "./commands/note/NoteSlashCmd"; +import { UnbanMsgCmd } from "./commands/unban/UnbanMsgCmd"; +import { UnbanSlashCmd } from "./commands/unban/UnbanSlashCmd"; +import { UnhideCaseMsgCmd } from "./commands/unhidecase/UnhideCaseMsgCmd"; +import { UnhideCaseSlashCmd } from "./commands/unhidecase/UnhideCaseSlashCmd"; +import { UnmuteMsgCmd } from "./commands/unmute/UnmuteMsgCmd"; +import { UnmuteSlashCmd } from "./commands/unmute/UnmuteSlashCmd"; +import { UpdateMsgCmd } from "./commands/update/UpdateMsgCmd"; +import { UpdateSlashCmd } from "./commands/update/UpdateSlashCmd"; +import { WarnMsgCmd } from "./commands/warn/WarnMsgCmd"; +import { WarnSlashCmd } from "./commands/warn/WarnSlashCmd"; import { AuditLogEvents } from "./events/AuditLogEvents"; import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt"; import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt"; @@ -148,33 +166,53 @@ export const ModActionsPlugin = zeppelinGuildPlugin()({ name: "mod", description: "Moderation actions", defaultMemberPermissions: "0", - subcommands: [{ type: "slash", ...NoteSlashCmd }], + subcommands: [ + { type: "slash", ...AddCaseSlashCmd }, + { type: "slash", ...BanSlashCmd }, + { type: "slash", ...CaseSlashCmd }, + { type: "slash", ...CasesSlashCmd }, + { type: "slash", ...DeleteCaseSlashCmd }, + { type: "slash", ...ForceBanSlashCmd }, + { type: "slash", ...ForceMuteSlashCmd }, + { type: "slash", ...ForceUnmuteSlashCmd }, + { type: "slash", ...HideCaseSlashCmd }, + { type: "slash", ...KickSlashCmd }, + { type: "slash", ...MassBanSlashCmd }, + { type: "slash", ...MassMuteSlashSlashCmd }, + { type: "slash", ...MassUnbanSlashCmd }, + { type: "slash", ...MuteSlashCmd }, + { type: "slash", ...NoteSlashCmd }, + { type: "slash", ...UnbanSlashCmd }, + { type: "slash", ...UnhideCaseSlashCmd }, + { type: "slash", ...UnmuteSlashCmd }, + { type: "slash", ...UpdateSlashCmd }, + { type: "slash", ...WarnSlashCmd }, + ], }), ], messageCommands: [ - UpdateCmd, + UpdateMsgCmd, NoteMsgCmd, - WarnCmd, - MuteCmd, - ForcemuteCmd, - UnmuteCmd, - ForceUnmuteCmd, - KickCmd, - SoftbanCmd, - BanCmd, - UnbanCmd, - ForcebanCmd, - MassbanCmd, - MassmuteCmd, - MassunbanCmd, - AddCaseCmd, - CaseCmd, - CasesUserCmd, - CasesModCmd, - HideCaseCmd, - UnhideCaseCmd, - DeleteCaseCmd, + WarnMsgCmd, + MuteMsgCmd, + ForceMuteMsgCmd, + UnmuteMsgCmd, + ForceUnmuteMsgCmd, + KickMsgCmd, + BanMsgCmd, + UnbanMsgCmd, + ForceBanMsgCmd, + MassBanMsgCmd, + MassMuteMsgCmd, + MassUnbanMsgCmd, + AddCaseMsgCmd, + CaseMsgCmd, + CasesUserMsgCmd, + CasesModMsgCmd, + HideCaseMsgCmd, + UnhideCaseMsgCmd, + DeleteCaseMsgCmd, ], public: { @@ -198,7 +236,7 @@ export const ModActionsPlugin = zeppelinGuildPlugin()({ updateCase(pluginData) { return (msg: Message, caseNumber: number | null, note: string) => { - updateCase(pluginData, msg, { caseNumber, note }); + updateCase(pluginData, msg.channel, msg.author, caseNumber ?? undefined, note, [...msg.attachments.values()]); }; }, diff --git a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts b/backend/src/plugins/ModActions/commands/AddCaseCmd.ts deleted file mode 100644 index dc8df97f..00000000 --- a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { CaseTypes } from "../../../data/CaseTypes"; -import { Case } from "../../../data/entities/Case"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; -import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { renderUserUsername, resolveMember, resolveUser } from "../../../utils"; -import { LogsPlugin } from "../../Logs/LogsPlugin"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; -import { modActionsMsgCmd } from "../types"; - -const opts = { - mod: ct.member({ option: true }), -}; - -export const AddCaseCmd = modActionsMsgCmd({ - 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.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - // 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 (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -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.values()]); - - // 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 : undefined, - }); - - if (user) { - sendSuccessMessage( - pluginData, - msg.channel, - `Case #${theCase.case_number} created for **${renderUserUsername(user)}**`, - ); - } else { - sendSuccessMessage(pluginData, msg.channel, `Case #${theCase.case_number} created`); - } - - // Log the action - pluginData.getPlugin(LogsPlugin).logCaseCreate({ - mod: 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 deleted file mode 100644 index f0a20d5f..00000000 --- a/backend/src/plugins/ModActions/commands/BanCmd.ts +++ /dev/null @@ -1,225 +0,0 @@ -import humanizeDuration from "humanize-duration"; -import { getMemberLevel } from "knub/helpers"; -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { CaseTypes } from "../../../data/CaseTypes"; -import { clearExpiringTempban, registerExpiringTempban } from "../../../data/loops/expiringTempbansLoop"; -import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; -import { renderUserUsername, resolveMember, resolveUser } from "../../../utils"; -import { banLock } from "../../../utils/lockNameHelpers"; -import { waitForButtonConfirm } from "../../../utils/waitForInteraction"; -import { LogsPlugin } from "../../Logs/LogsPlugin"; -import { banUserId } from "../functions/banUserId"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; -import { isBanned } from "../functions/isBanned"; -import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; -import { modActionsMsgCmd } from "../types"; - -const opts = { - mod: ct.member({ option: true }), - notify: ct.string({ option: true }), - "notify-channel": ct.textChannel({ option: true }), - "delete-days": ct.number({ option: true, shortcut: "d" }), -}; - -export const BanCmd = modActionsMsgCmd({ - trigger: "ban", - permission: "can_ban", - description: "Ban or Tempban 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.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - const memberToBan = await resolveMember(pluginData.client, pluginData.guild, user.id); - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id }))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); - return; - } - - mod = args.mod; - } - - const time = args["time"] ? args["time"] : null; - const reason = formatReasonWithAttachments(args.reason, [...msg.attachments.values()]); - - // acquire a lock because of the needed user-inputs below (if banned/not on server) - const lock = await pluginData.locks.acquire(banLock(user)); - let forceban = false; - const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); - - if (!memberToBan) { - const banned = await isBanned(pluginData, user.id); - - if (!banned) { - // Ask the mod if we should upgrade to a forceban as the user is not on the server - const reply = await waitForButtonConfirm( - msg.channel, - { content: "User not on server, forceban instead?" }, - { confirmText: "Yes", cancelText: "No", restrictToId: msg.member.id }, - ); - - if (!reply) { - sendErrorMessage(pluginData, msg.channel, "User not on server, ban cancelled by moderator"); - lock.unlock(); - return; - } else { - forceban = true; - } - } - - // Abort if trying to ban user indefinitely if they are already banned indefinitely - if (!existingTempban && !time) { - sendErrorMessage(pluginData, msg.channel, `User is already banned indefinitely.`); - return; - } - - // Ask the mod if we should update the existing ban - const reply = await waitForButtonConfirm( - msg.channel, - { content: "Failed to message the user. Log the warning anyway?" }, - { confirmText: "Yes", cancelText: "No", restrictToId: msg.member.id }, - ); - - if (!reply) { - sendErrorMessage(pluginData, msg.channel, "User already banned, update cancelled by moderator"); - lock.unlock(); - return; - } - - // Update or add new tempban / remove old tempban - if (time && time > 0) { - if (existingTempban) { - await pluginData.state.tempbans.updateExpiryTime(user.id, time, mod.id); - } else { - await pluginData.state.tempbans.addTempban(user.id, time, mod.id); - } - const tempban = (await pluginData.state.tempbans.findExistingTempbanForUserId(user.id))!; - registerExpiringTempban(tempban); - } else if (existingTempban) { - clearExpiringTempban(existingTempban); - pluginData.state.tempbans.clear(user.id); - } - - // Create a new case for the updated ban since we never stored the old case id and log the action - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const createdCase = await casesPlugin.createCase({ - modId: mod.id, - type: CaseTypes.Ban, - userId: user.id, - reason, - noteDetails: [`Ban updated to ${time ? humanizeDuration(time) : "indefinite"}`], - }); - if (time) { - pluginData.getPlugin(LogsPlugin).logMemberTimedBan({ - mod: mod.user, - user, - caseNumber: createdCase.case_number, - reason, - banTime: humanizeDuration(time), - }); - } else { - pluginData.getPlugin(LogsPlugin).logMemberBan({ - mod: mod.user, - user, - caseNumber: createdCase.case_number, - reason, - }); - } - - sendSuccessMessage( - pluginData, - msg.channel, - `Ban updated to ${time ? "expire in " + humanizeDuration(time) + " from now" : "indefinite"}`, - ); - lock.unlock(); - return; - } - - // Make sure we're allowed to ban this member if they are on the server - if (!forceban && !canActOn(pluginData, msg.member, memberToBan!)) { - const ourLevel = getMemberLevel(pluginData, msg.member); - const targetLevel = getMemberLevel(pluginData, memberToBan!); - sendErrorMessage( - pluginData, - msg.channel, - `Cannot ban: target permission level is equal or higher to yours, ${targetLevel} >= ${ourLevel}`, - ); - lock.unlock(); - return; - } - - let contactMethods; - try { - contactMethods = readContactMethodsFromArgs(args); - } catch (e) { - sendErrorMessage(pluginData, msg.channel, e.message); - lock.unlock(); - return; - } - - const deleteMessageDays = - args["delete-days"] ?? (await pluginData.config.getForMessage(msg)).ban_delete_message_days; - const banResult = await banUserId( - pluginData, - user.id, - reason, - { - contactMethods, - caseArgs: { - modId: mod.id, - ppId: mod.id !== msg.author.id ? msg.author.id : undefined, - }, - deleteMessageDays, - modId: mod.id, - }, - time, - ); - - if (banResult.status === "failed") { - sendErrorMessage(pluginData, msg.channel, `Failed to ban member: ${banResult.error}`); - lock.unlock(); - return; - } - - let forTime = ""; - if (time && time > 0) { - forTime = `for ${humanizeDuration(time)} `; - } - - // Confirm the action to the moderator - let response = ""; - if (!forceban) { - response = `Banned **${renderUserUsername(user)}** ${forTime}(Case #${banResult.case.case_number})`; - if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`; - } else { - response = `Member forcebanned ${forTime}(Case #${banResult.case.case_number})`; - } - - lock.unlock(); - sendSuccessMessage(pluginData, msg.channel, response); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/CaseCmd.ts b/backend/src/plugins/ModActions/commands/CaseCmd.ts deleted file mode 100644 index 8a92ed3f..00000000 --- a/backend/src/plugins/ModActions/commands/CaseCmd.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; -import { sendErrorMessage } from "../../../pluginUtils"; -import { modActionsMsgCmd } from "../types"; - -export const CaseCmd = modActionsMsgCmd({ - 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.author.id); - msg.channel.send(embed); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/CasesModCmd.ts b/backend/src/plugins/ModActions/commands/CasesModCmd.ts deleted file mode 100644 index e07176f5..00000000 --- a/backend/src/plugins/ModActions/commands/CasesModCmd.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { APIEmbed, User } from "discord.js"; -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { sendErrorMessage } from "../../../pluginUtils"; -import { emptyEmbedValue, resolveUser, trimLines } from "../../../utils"; -import { asyncMap } from "../../../utils/async"; -import { createPaginatedMessage } from "../../../utils/createPaginatedMessage"; -import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields"; -import { getGuildPrefix } from "../../../utils/getGuildPrefix"; -import { CasesPlugin } from "../../Cases/CasesPlugin"; -import { modActionsMsgCmd } from "../types"; - -const opts = { - mod: ct.userId({ option: true }), -}; - -const casesPerPage = 5; - -export const CasesModCmd = modActionsMsgCmd({ - trigger: ["cases", "modlogs", "infractions"], - 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 || msg.author.id; - const mod = await resolveUser(pluginData.client, modId); - const modName = mod instanceof User ? mod.tag : modId; - - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const totalCases = await casesPlugin.getTotalCasesByMod(modId); - - if (totalCases === 0) { - sendErrorMessage(pluginData, msg.channel, `No cases by **${modName}**`); - return; - } - - const totalPages = Math.max(Math.ceil(totalCases / casesPerPage), 1); - const prefix = getGuildPrefix(pluginData); - - createPaginatedMessage( - pluginData.client, - msg.channel, - totalPages, - async (page) => { - const cases = await casesPlugin.getRecentCasesByMod(modId, casesPerPage, (page - 1) * casesPerPage); - const lines = await asyncMap(cases, (c) => casesPlugin.getCaseSummary(c, true, msg.author.id)); - - const firstCaseNum = (page - 1) * casesPerPage + 1; - const lastCaseNum = page * casesPerPage; - const title = `Most recent cases ${firstCaseNum}-${lastCaseNum} of ${totalCases} by ${modName}`; - - const embed = { - author: { - name: title, - icon_url: mod instanceof User ? mod.displayAvatarURL() : undefined, - }, - fields: [ - ...getChunkedEmbedFields(emptyEmbedValue, lines.join("\n")), - { - name: emptyEmbedValue, - value: trimLines(` - Use \`${prefix}case \` to see more information about an individual case - Use \`${prefix}cases \` to see a specific user's cases - `), - }, - ], - } satisfies APIEmbed; - - return { embeds: [embed] }; - }, - { - limitToUserId: msg.author.id, - }, - ); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts deleted file mode 100644 index a92ea75d..00000000 --- a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { APIEmbed, User } from "discord.js"; -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { CaseTypes } from "../../../data/CaseTypes"; -import { sendErrorMessage } from "../../../pluginUtils"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; -import { UnknownUser, chunkArray, emptyEmbedValue, renderUserUsername, resolveUser, trimLines } from "../../../utils"; -import { asyncMap } from "../../../utils/async"; -import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields"; -import { getGuildPrefix } from "../../../utils/getGuildPrefix"; -import { modActionsMsgCmd } from "../types"; - -const opts = { - expand: ct.bool({ option: true, isSwitch: true, shortcut: "e" }), - hidden: ct.bool({ option: true, isSwitch: true, shortcut: "h" }), - reverseFilters: ct.switchOption({ def: false, shortcut: "r" }), - notes: ct.switchOption({ def: false, shortcut: "n" }), - warns: ct.switchOption({ def: false, shortcut: "w" }), - mutes: ct.switchOption({ def: false, shortcut: "m" }), - unmutes: ct.switchOption({ def: false, shortcut: "um" }), - bans: ct.switchOption({ def: false, shortcut: "b" }), - unbans: ct.switchOption({ def: false, shortcut: "ub" }), -}; - -export const CasesUserCmd = modActionsMsgCmd({ - trigger: ["cases", "modlogs"], - 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.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - let cases = await pluginData.state.cases.with("notes").getByUserId(user.id); - - const typesToShow: CaseTypes[] = []; - if (args.notes) typesToShow.push(CaseTypes.Note); - if (args.warns) typesToShow.push(CaseTypes.Warn); - if (args.mutes) typesToShow.push(CaseTypes.Mute); - if (args.unmutes) typesToShow.push(CaseTypes.Unmute); - if (args.bans) typesToShow.push(CaseTypes.Ban); - if (args.unbans) typesToShow.push(CaseTypes.Unban); - - if (typesToShow.length > 0) { - // Reversed: Hide specified types - if (args.reverseFilters) cases = cases.filter((c) => !typesToShow.includes(c.type)); - // Normal: Show only specified types - else cases = cases.filter((c) => typesToShow.includes(c.type)); - } - - 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 : renderUserUsername(user); - - if (cases.length === 0) { - msg.channel.send(`No cases found for **${userName}**`); - } else { - const casesToDisplay = args.hidden ? cases : normalCases; - if (!casesToDisplay.length) { - msg.channel.send( - `No normal cases found for **${userName}**. Use "-hidden" to show ${cases.length} hidden cases.`, - ); - return; - } - - if (args.expand) { - if (casesToDisplay.length > 8) { - msg.channel.send("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.send(embed); - } - } else { - // Compact view (= regular message with a preview of each case) - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const lines = await asyncMap(casesToDisplay, (c) => casesPlugin.getCaseSummary(c, true, msg.author.id)); - - const prefix = getGuildPrefix(pluginData); - const linesPerChunk = 10; - const lineChunks = chunkArray(lines, linesPerChunk); - - const footerField = { - name: emptyEmbedValue, - value: trimLines(` - Use \`${prefix}case \` to see more information about an individual case - `), - }; - - for (const [i, linesInChunk] of lineChunks.entries()) { - const isLastChunk = i === lineChunks.length - 1; - - if (isLastChunk && !args.hidden && hiddenCases.length) { - if (hiddenCases.length === 1) { - linesInChunk.push(`*+${hiddenCases.length} hidden case, use "-hidden" to show it*`); - } else { - linesInChunk.push(`*+${hiddenCases.length} hidden cases, use "-hidden" to show them*`); - } - } - - const chunkStart = i * linesPerChunk + 1; - const chunkEnd = Math.min((i + 1) * linesPerChunk, lines.length); - - const embed = { - author: { - name: - lineChunks.length === 1 - ? `Cases for ${userName} (${lines.length} total)` - : `Cases ${chunkStart}–${chunkEnd} of ${lines.length} for ${userName}`, - icon_url: user instanceof User ? user.displayAvatarURL() : undefined, - }, - fields: [ - ...getChunkedEmbedFields(emptyEmbedValue, linesInChunk.join("\n")), - ...(isLastChunk ? [footerField] : []), - ], - } satisfies APIEmbed; - - msg.channel.send({ embeds: [embed] }); - } - } - } - }, -}); diff --git a/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts b/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts deleted file mode 100644 index cb3c56d8..00000000 --- a/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { helpers } from "knub"; -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { Case } from "../../../data/entities/Case"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { SECONDS, trimLines } from "../../../utils"; -import { CasesPlugin } from "../../Cases/CasesPlugin"; -import { LogsPlugin } from "../../Logs/LogsPlugin"; -import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; -import { modActionsMsgCmd } from "../types"; - -export const DeleteCaseCmd = modActionsMsgCmd({ - trigger: ["delete_case", "deletecase"], - permission: "can_deletecase", - description: trimLines(` - Delete the specified case. This operation can *not* be reversed. - It is generally recommended to use \`!hidecase\` instead when possible. - `), - - signature: { - caseNumber: ct.number({ rest: true }), - - force: ct.switchOption({ def: false, shortcut: "f" }), - }, - - async run({ pluginData, message, args }) { - const failed: number[] = []; - const validCases: Case[] = []; - let cancelled = 0; - - for (const num of args.caseNumber) { - const theCase = await pluginData.state.cases.findByCaseNumber(num); - if (!theCase) { - failed.push(num); - continue; - } - - validCases.push(theCase); - } - - if (failed.length === args.caseNumber.length) { - sendErrorMessage(pluginData, message.channel, "None of the cases were found!"); - return; - } - - for (const theCase of validCases) { - if (!args.force) { - const cases = pluginData.getPlugin(CasesPlugin); - const embedContent = await cases.getCaseEmbed(theCase); - message.channel.send({ - ...embedContent, - content: "Delete the following case? Answer 'Yes' to continue, 'No' to cancel.", - }); - - const reply = await helpers.waitForReply(pluginData.client, message.channel, message.author.id, 15 * SECONDS); - const normalizedReply = (reply?.content || "").toLowerCase().trim(); - if (normalizedReply !== "yes" && normalizedReply !== "y") { - message.channel.send("Cancelled. Case was not deleted."); - cancelled++; - continue; - } - } - - const deletedByName = message.author.tag; - - const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); - const deletedAt = timeAndDate.inGuildTz().format(timeAndDate.getDateFormat("pretty_datetime")); - - await pluginData.state.cases.softDelete( - theCase.id, - message.author.id, - deletedByName, - `Case deleted by **${deletedByName}** (\`${message.author.id}\`) on ${deletedAt}`, - ); - - const logs = pluginData.getPlugin(LogsPlugin); - logs.logCaseDelete({ - mod: message.member, - case: theCase, - }); - } - - const failedAddendum = - failed.length > 0 - ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` - : ""; - const amt = validCases.length - cancelled; - if (amt === 0) { - sendErrorMessage(pluginData, message.channel, "All deletions were cancelled, no cases were deleted."); - return; - } - - sendSuccessMessage( - pluginData, - message.channel, - `${amt} case${amt === 1 ? " was" : "s were"} deleted!${failedAddendum}`, - ); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/ForcebanCmd.ts b/backend/src/plugins/ModActions/commands/ForcebanCmd.ts deleted file mode 100644 index 251be232..00000000 --- a/backend/src/plugins/ModActions/commands/ForcebanCmd.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Snowflake } from "discord.js"; -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { CaseTypes } from "../../../data/CaseTypes"; -import { LogType } from "../../../data/LogType"; -import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; -import { DAYS, MINUTES, resolveMember, resolveUser } from "../../../utils"; -import { LogsPlugin } from "../../Logs/LogsPlugin"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; -import { ignoreEvent } from "../functions/ignoreEvent"; -import { isBanned } from "../functions/isBanned"; -import { IgnoredEventType, modActionsMsgCmd } from "../types"; - -const opts = { - mod: ct.member({ option: true }), -}; - -export const ForcebanCmd = modActionsMsgCmd({ - 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.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - // 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 (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); - return; - } - - mod = args.mod; - } - - const reason = formatReasonWithAttachments(args.reason, [...msg.attachments.values()]); - - ignoreEvent(pluginData, IgnoredEventType.Ban, user.id); - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id); - - try { - // FIXME: Use banUserId()? - await pluginData.guild.bans.create(user.id as Snowflake, { - deleteMessageSeconds: (1 * DAYS) / MINUTES, - reason: reason ?? undefined, - }); - } catch { - 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 : undefined, - }); - - // Confirm the action - sendSuccessMessage(pluginData, msg.channel, `Member forcebanned (Case #${createdCase.case_number})`); - - // Log the action - pluginData.getPlugin(LogsPlugin).logMemberForceban({ - mod, - userId: user.id, - caseNumber: createdCase.case_number, - reason, - }); - - pluginData.state.events.emit("ban", user.id, reason); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts b/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts deleted file mode 100644 index 4b6f90da..00000000 --- a/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils"; -import { resolveMember, resolveUser } from "../../../utils"; -import { actualMuteUserCmd } from "../functions/actualMuteUserCmd"; -import { modActionsMsgCmd } from "../types"; - -const opts = { - mod: ct.member({ option: true }), - notify: ct.string({ option: true }), - "notify-channel": ct.textChannel({ option: true }), -}; - -export const ForcemuteCmd = modActionsMsgCmd({ - 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.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - 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, notify: "none" }); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/HideCaseCmd.ts b/backend/src/plugins/ModActions/commands/HideCaseCmd.ts deleted file mode 100644 index b08756db..00000000 --- a/backend/src/plugins/ModActions/commands/HideCaseCmd.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { modActionsMsgCmd } from "../types"; - -export const HideCaseCmd = modActionsMsgCmd({ - 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({ rest: true }), - }, - ], - - async run({ pluginData, message: msg, args }) { - const failed: number[] = []; - - for (const num of args.caseNum) { - const theCase = await pluginData.state.cases.findByCaseNumber(num); - if (!theCase) { - failed.push(num); - continue; - } - - await pluginData.state.cases.setHidden(theCase.id, true); - } - - if (failed.length === args.caseNum.length) { - sendErrorMessage(pluginData, msg.channel, "None of the cases were found!"); - return; - } - const failedAddendum = - failed.length > 0 - ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` - : ""; - - const amt = args.caseNum.length - failed.length; - sendSuccessMessage( - pluginData, - msg.channel, - `${amt} case${amt === 1 ? " is" : "s are"} now hidden! Use \`unhidecase\` to unhide them.${failedAddendum}`, - ); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/KickCmd.ts b/backend/src/plugins/ModActions/commands/KickCmd.ts deleted file mode 100644 index 2e32f3e2..00000000 --- a/backend/src/plugins/ModActions/commands/KickCmd.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { actualKickMemberCmd } from "../functions/actualKickMemberCmd"; -import { modActionsMsgCmd } from "../types"; - -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 = modActionsMsgCmd({ - 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 deleted file mode 100644 index 4769cd51..00000000 --- a/backend/src/plugins/ModActions/commands/MassBanCmd.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Snowflake } from "discord.js"; -import { waitForReply } from "knub/helpers"; -import { performance } from "perf_hooks"; -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { CaseTypes } from "../../../data/CaseTypes"; -import { LogType } from "../../../data/LogType"; -import { humanizeDurationShort } from "../../../humanizeDurationShort"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; -import { DAYS, MINUTES, SECONDS, noop } from "../../../utils"; -import { LogsPlugin } from "../../Logs/LogsPlugin"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; -import { ignoreEvent } from "../functions/ignoreEvent"; -import { IgnoredEventType, modActionsMsgCmd } from "../types"; - -export const MassbanCmd = modActionsMsgCmd({ - 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.send("Ban reason? `cancel` to cancel"); - const banReasonReply = await waitForReply(pluginData.client, msg.channel, 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.values()]); - - // Verify we can act on each of the users specified - for (const userId of args.userIds) { - const member = pluginData.guild.members.cache.get(userId as Snowflake); // 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; - } - } - - // Show a loading indicator since this can take a while - const maxWaitTime = pluginData.state.massbanQueue.timeout * pluginData.state.massbanQueue.length; - const maxWaitTimeFormatted = humanizeDurationShort(maxWaitTime, { round: true }); - const initialLoadingText = - pluginData.state.massbanQueue.length === 0 - ? "Banning..." - : `Massban queued. Waiting for previous massban to finish (max wait ${maxWaitTimeFormatted}).`; - const loadingMsg = await msg.channel.send(initialLoadingText); - - const waitTimeStart = performance.now(); - const waitingInterval = setInterval(() => { - const waitTime = humanizeDurationShort(performance.now() - waitTimeStart, { round: true }); - loadingMsg - .edit(`Massban queued. Still waiting for previous massban to finish (waited ${waitTime}).`) - .catch(() => clearInterval(waitingInterval)); - }, 1 * MINUTES); - - pluginData.state.massbanQueue.add(async () => { - clearInterval(waitingInterval); - - if (pluginData.state.unloaded) { - void loadingMsg.delete().catch(noop); - return; - } - - void loadingMsg.edit("Banning...").catch(noop); - - // Ban each user and count failed bans (if any) - const startTime = performance.now(); - const failedBans: string[] = []; - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const deleteDays = (await pluginData.config.getForMessage(msg)).ban_delete_message_days; - for (const [i, userId] of args.userIds.entries()) { - if (pluginData.state.unloaded) { - break; - } - - try { - // Ignore automatic ban cases and logs - // We create our own cases below and post a single "mass banned" log instead - ignoreEvent(pluginData, IgnoredEventType.Ban, userId, 30 * MINUTES); - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId, 30 * MINUTES); - - await pluginData.guild.bans.create(userId as Snowflake, { - deleteMessageSeconds: (deleteDays * DAYS) / SECONDS, - reason: banReason, - }); - - await casesPlugin.createCase({ - userId, - modId: msg.author.id, - type: CaseTypes.Ban, - reason: `Mass ban: ${banReason}`, - postInCaseLogOverride: false, - }); - - pluginData.state.events.emit("ban", userId, banReason); - } catch { - failedBans.push(userId); - } - - // Send a status update every 10 bans - if ((i + 1) % 10 === 0) { - loadingMsg.edit(`Banning... ${i + 1}/${args.userIds.length}`).catch(noop); - } - } - - const totalTime = performance.now() - startTime; - const formattedTimeTaken = humanizeDurationShort(totalTime, { round: true }); - - // Clear loading indicator - loadingMsg.delete().catch(noop); - - 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.getPlugin(LogsPlugin).logMassBan({ - mod: msg.author, - count: successfulBanCount, - reason: banReason, - }); - - if (failedBans.length) { - sendSuccessMessage( - pluginData, - msg.channel, - `Banned ${successfulBanCount} users in ${formattedTimeTaken}, ${ - failedBans.length - } failed: ${failedBans.join(" ")}`, - ); - } else { - sendSuccessMessage( - pluginData, - msg.channel, - `Banned ${successfulBanCount} users successfully in ${formattedTimeTaken}`, - ); - } - } - }); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/MassUnbanCmd.ts b/backend/src/plugins/ModActions/commands/MassUnbanCmd.ts deleted file mode 100644 index a873b4c6..00000000 --- a/backend/src/plugins/ModActions/commands/MassUnbanCmd.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Snowflake } from "discord.js"; -import { waitForReply } from "knub/helpers"; -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { CaseTypes } from "../../../data/CaseTypes"; -import { LogType } from "../../../data/LogType"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { CasesPlugin } from "../../Cases/CasesPlugin"; -import { LogsPlugin } from "../../Logs/LogsPlugin"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; -import { ignoreEvent } from "../functions/ignoreEvent"; -import { isBanned } from "../functions/isBanned"; -import { IgnoredEventType, modActionsMsgCmd } from "../types"; - -export const MassunbanCmd = modActionsMsgCmd({ - trigger: "massunban", - permission: "can_massunban", - description: "Mass-unban 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 mass-unban max 100 users at once`); - return; - } - - // Ask for unban reason (cleaner this way instead of trying to cram it into the args) - msg.channel.send("Unban reason? `cancel` to cancel"); - const unbanReasonReply = await waitForReply(pluginData.client, msg.channel, msg.author.id); - if (!unbanReasonReply || !unbanReasonReply.content || unbanReasonReply.content.toLowerCase().trim() === "cancel") { - sendErrorMessage(pluginData, msg.channel, "Cancelled"); - return; - } - - const unbanReason = formatReasonWithAttachments(unbanReasonReply.content, [...msg.attachments.values()]); - - // Ignore automatic unban cases and logs for these users - // We'll create our own cases below and post a single "mass unbanned" log instead - args.userIds.forEach((userId) => { - // Use longer timeouts since this can take a while - ignoreEvent(pluginData, IgnoredEventType.Unban, userId, 120 * 1000); - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, userId, 120 * 1000); - }); - - // Show a loading indicator since this can take a while - const loadingMsg = await msg.channel.send("Unbanning..."); - - // Unban each user and count failed unbans (if any) - const failedUnbans: Array<{ userId: string; reason: UnbanFailReasons }> = []; - const casesPlugin = pluginData.getPlugin(CasesPlugin); - for (const userId of args.userIds) { - if (!(await isBanned(pluginData, userId))) { - failedUnbans.push({ userId, reason: UnbanFailReasons.NOT_BANNED }); - continue; - } - - try { - await pluginData.guild.bans.remove(userId as Snowflake, unbanReason ?? undefined); - - await casesPlugin.createCase({ - userId, - modId: msg.author.id, - type: CaseTypes.Unban, - reason: `Mass unban: ${unbanReason}`, - postInCaseLogOverride: false, - }); - } catch { - failedUnbans.push({ userId, reason: UnbanFailReasons.UNBAN_FAILED }); - } - } - - // Clear loading indicator - loadingMsg.delete(); - - const successfulUnbanCount = args.userIds.length - failedUnbans.length; - if (successfulUnbanCount === 0) { - // All unbans failed - don't create a log entry and notify the user - sendErrorMessage(pluginData, msg.channel, "All unbans failed. Make sure the IDs are valid and banned."); - } else { - // Some or all unbans were successful. Create a log entry for the mass unban and notify the user. - pluginData.getPlugin(LogsPlugin).logMassUnban({ - mod: msg.author, - count: successfulUnbanCount, - reason: unbanReason, - }); - - if (failedUnbans.length) { - const notBanned = failedUnbans.filter((x) => x.reason === UnbanFailReasons.NOT_BANNED); - const unbanFailed = failedUnbans.filter((x) => x.reason === UnbanFailReasons.UNBAN_FAILED); - - let failedMsg = ""; - if (notBanned.length > 0) { - failedMsg += `${notBanned.length}x ${UnbanFailReasons.NOT_BANNED}:`; - notBanned.forEach((fail) => { - failedMsg += " " + fail.userId; - }); - } - if (unbanFailed.length > 0) { - failedMsg += `\n${unbanFailed.length}x ${UnbanFailReasons.UNBAN_FAILED}:`; - unbanFailed.forEach((fail) => { - failedMsg += " " + fail.userId; - }); - } - - sendSuccessMessage( - pluginData, - msg.channel, - `Unbanned ${successfulUnbanCount} users, ${failedUnbans.length} failed:\n${failedMsg}`, - ); - } else { - sendSuccessMessage(pluginData, msg.channel, `Unbanned ${successfulUnbanCount} users successfully`); - } - } - }, -}); - -enum UnbanFailReasons { - NOT_BANNED = "Not banned", - UNBAN_FAILED = "Unban failed", -} diff --git a/backend/src/plugins/ModActions/commands/MassmuteCmd.ts b/backend/src/plugins/ModActions/commands/MassmuteCmd.ts deleted file mode 100644 index 65422f0f..00000000 --- a/backend/src/plugins/ModActions/commands/MassmuteCmd.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Snowflake } from "discord.js"; -import { waitForReply } from "knub/helpers"; -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { LogType } from "../../../data/LogType"; -import { logger } from "../../../logger"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin"; -import { LogsPlugin } from "../../Logs/LogsPlugin"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; -import { modActionsMsgCmd } from "../types"; - -export const MassmuteCmd = modActionsMsgCmd({ - trigger: "massmute", - permission: "can_massmute", - description: "Mass-mute 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 massmute max 100 users at once`); - return; - } - - // Ask for mute reason - msg.channel.send("Mute reason? `cancel` to cancel"); - const muteReasonReceived = await waitForReply(pluginData.client, msg.channel, msg.author.id); - if ( - !muteReasonReceived || - !muteReasonReceived.content || - muteReasonReceived.content.toLowerCase().trim() === "cancel" - ) { - sendErrorMessage(pluginData, msg.channel, "Cancelled"); - return; - } - - const muteReason = formatReasonWithAttachments(muteReasonReceived.content, [...msg.attachments.values()]); - - // Verify we can act upon all users - for (const userId of args.userIds) { - const member = pluginData.guild.members.cache.get(userId as Snowflake); - if (member && !canActOn(pluginData, msg.member, member)) { - sendErrorMessage(pluginData, msg.channel, "Cannot massmute one or more users: insufficient permissions"); - return; - } - } - - // Ignore automatic mute cases and logs for these users - // We'll create our own cases below and post a single "mass muted" log instead - args.userIds.forEach((userId) => { - // Use longer timeouts since this can take a while - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_MUTE, userId, 120 * 1000); - }); - - // Show loading indicator - const loadingMsg = await msg.channel.send("Muting..."); - - // Mute everyone and count fails - const modId = msg.author.id; - const failedMutes: string[] = []; - const mutesPlugin = pluginData.getPlugin(MutesPlugin); - for (const userId of args.userIds) { - try { - await mutesPlugin.muteUser(userId, 0, `Mass mute: ${muteReason}`, { - caseArgs: { - modId, - }, - }); - } catch (e) { - logger.info(e); - failedMutes.push(userId); - } - } - - // Clear loading indicator - loadingMsg.delete(); - - const successfulMuteCount = args.userIds.length - failedMutes.length; - if (successfulMuteCount === 0) { - // All mutes failed - sendErrorMessage(pluginData, msg.channel, "All mutes failed. Make sure the IDs are valid."); - } else { - // Success on all or some mutes - pluginData.getPlugin(LogsPlugin).logMassMute({ - mod: msg.author, - count: successfulMuteCount, - }); - - if (failedMutes.length) { - sendSuccessMessage( - pluginData, - msg.channel, - `Muted ${successfulMuteCount} users, ${failedMutes.length} failed: ${failedMutes.join(" ")}`, - ); - } else { - sendSuccessMessage(pluginData, msg.channel, `Muted ${successfulMuteCount} users successfully`); - } - } - }, -}); diff --git a/backend/src/plugins/ModActions/commands/SoftbanCommand.ts b/backend/src/plugins/ModActions/commands/SoftbanCommand.ts deleted file mode 100644 index a5806cdd..00000000 --- a/backend/src/plugins/ModActions/commands/SoftbanCommand.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { trimPluginDescription } from "../../../utils"; -import { actualKickMemberCmd } from "../functions/actualKickMemberCmd"; -import { modActionsMsgCmd } from "../types"; - -const opts = { - mod: ct.member({ option: true }), - notify: ct.string({ option: true }), - "notify-channel": ct.textChannel({ option: true }), -}; - -export const SoftbanCmd = modActionsMsgCmd({ - 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.send( - "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 deleted file mode 100644 index d232f4e4..00000000 --- a/backend/src/plugins/ModActions/commands/UnbanCmd.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Snowflake } from "discord.js"; -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { CaseTypes } from "../../../data/CaseTypes"; -import { LogType } from "../../../data/LogType"; -import { clearExpiringTempban } from "../../../data/loops/expiringTempbansLoop"; -import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; -import { resolveUser } from "../../../utils"; -import { LogsPlugin } from "../../Logs/LogsPlugin"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; -import { ignoreEvent } from "../functions/ignoreEvent"; -import { IgnoredEventType, modActionsMsgCmd } from "../types"; - -const opts = { - mod: ct.member({ option: true }), -}; - -export const UnbanCmd = modActionsMsgCmd({ - trigger: "unban", - permission: "can_unban", - 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.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - 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 (!(await hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id }))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); - return; - } - - mod = args.mod; - } - - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, user.id); - const reason = formatReasonWithAttachments(args.reason, [...msg.attachments.values()]); - - try { - ignoreEvent(pluginData, IgnoredEventType.Unban, user.id); - await pluginData.guild.bans.remove(user.id as Snowflake, reason ?? undefined); - } catch { - sendErrorMessage(pluginData, msg.channel, "Failed to unban member; are you sure they're banned?"); - return; - } - - // 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 : undefined, - }); - // Delete the tempban, if one exists - const tempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); - if (tempban) { - clearExpiringTempban(tempban); - await pluginData.state.tempbans.clear(user.id); - } - - // Confirm the action - sendSuccessMessage(pluginData, msg.channel, `Member unbanned (Case #${createdCase.case_number})`); - - // Log the action - pluginData.getPlugin(LogsPlugin).logMemberUnban({ - mod: mod.user, - userId: user.id, - caseNumber: createdCase.case_number, - reason: reason ?? "", - }); - - pluginData.state.events.emit("unban", user.id); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/UnhideCaseCmd.ts b/backend/src/plugins/ModActions/commands/UnhideCaseCmd.ts deleted file mode 100644 index 7c6eb774..00000000 --- a/backend/src/plugins/ModActions/commands/UnhideCaseCmd.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { modActionsMsgCmd } from "../types"; - -export const UnhideCaseCmd = modActionsMsgCmd({ - 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({ rest: true }), - }, - ], - - async run({ pluginData, message: msg, args }) { - const failed: number[] = []; - - for (const num of args.caseNum) { - const theCase = await pluginData.state.cases.findByCaseNumber(num); - if (!theCase) { - failed.push(num); - continue; - } - - await pluginData.state.cases.setHidden(theCase.id, false); - } - - if (failed.length === args.caseNum.length) { - sendErrorMessage(pluginData, msg.channel, "None of the cases were found!"); - return; - } - const failedAddendum = - failed.length > 0 - ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` - : ""; - - const amt = args.caseNum.length - failed.length; - sendSuccessMessage( - pluginData, - msg.channel, - `${amt} case${amt === 1 ? " is" : "s are"} no longer hidden!${failedAddendum}`, - ); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/WarnCmd.ts b/backend/src/plugins/ModActions/commands/WarnCmd.ts deleted file mode 100644 index 2b3121fd..00000000 --- a/backend/src/plugins/ModActions/commands/WarnCmd.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { CaseTypes } from "../../../data/CaseTypes"; -import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { errorMessage, renderUserUsername, resolveMember, resolveUser } from "../../../utils"; -import { waitForButtonConfirm } from "../../../utils/waitForInteraction"; -import { CasesPlugin } from "../../Cases/CasesPlugin"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; -import { isBanned } from "../functions/isBanned"; -import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; -import { warnMember } from "../functions/warnMember"; -import { modActionsMsgCmd } from "../types"; - -export const WarnCmd = modActionsMsgCmd({ - trigger: "warn", - permission: "can_warn", - description: "Send a warning to the specified user", - - signature: { - user: ct.string(), - reason: ct.string({ catchAll: true }), - - mod: ct.member({ option: true }), - notify: ct.string({ option: true }), - "notify-channel": ct.textChannel({ option: true }), - }, - - async run({ pluginData, message: msg, args }) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, user.id); - - if (!memberToWarn) { - const _isBanned = await isBanned(pluginData, user.id); - if (_isBanned) { - 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 warn this member - if (!canActOn(pluginData, msg.member, memberToWarn)) { - sendErrorMessage(pluginData, msg.channel, "Cannot warn: 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 (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { - msg.channel.send(errorMessage("You don't have permission to use -mod")); - return; - } - - mod = args.mod; - } - - const config = pluginData.config.get(); - const reason = formatReasonWithAttachments(args.reason, [...msg.attachments.values()]); - - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const priorWarnAmount = await casesPlugin.getCaseTypeAmountForUserId(memberToWarn.id, CaseTypes.Warn); - if (config.warn_notify_enabled && priorWarnAmount >= config.warn_notify_threshold) { - const reply = await waitForButtonConfirm( - msg.channel, - { content: config.warn_notify_message.replace("{priorWarnings}", `${priorWarnAmount}`) }, - { confirmText: "Yes", cancelText: "No", restrictToId: msg.member.id }, - ); - if (!reply) { - msg.channel.send(errorMessage("Warn cancelled by moderator")); - return; - } - } - - let contactMethods; - try { - contactMethods = readContactMethodsFromArgs(args); - } catch (e) { - sendErrorMessage(pluginData, msg.channel, e.message); - return; - } - - const warnResult = await warnMember(pluginData, memberToWarn, reason, { - contactMethods, - caseArgs: { - modId: mod.id, - ppId: mod.id !== msg.author.id ? msg.author.id : undefined, - reason, - }, - retryPromptChannel: msg.channel, - }); - - if (warnResult.status === "failed") { - sendErrorMessage(pluginData, msg.channel, "Failed to warn user"); - return; - } - - const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : ""; - - sendSuccessMessage( - pluginData, - msg.channel, - `Warned **${renderUserUsername(memberToWarn.user)}** (Case #${warnResult.case.case_number})${messageResultText}`, - ); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/addcase/AddCaseMsgCmd.ts b/backend/src/plugins/ModActions/commands/addcase/AddCaseMsgCmd.ts new file mode 100644 index 00000000..41c2a380 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/addcase/AddCaseMsgCmd.ts @@ -0,0 +1,63 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { CaseTypes } from "../../../../data/CaseTypes"; +import { hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { resolveUser } from "../../../../utils"; +import { actualAddCaseCmd } from "../../functions/actualCommands/actualAddCaseCmd"; +import { modActionsMsgCmd } from "../../types"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const AddCaseMsgCmd = modActionsMsgCmd({ + 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.id) { + sendErrorMessage(pluginData, msg.channel, `User not found`); + 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 (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -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; + } + + actualAddCaseCmd( + pluginData, + msg.channel, + msg.member, + mod, + [...msg.attachments.values()], + user, + type as keyof CaseTypes, + args.reason || "", + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/addcase/AddCaseSlashCmd.ts b/backend/src/plugins/ModActions/commands/addcase/AddCaseSlashCmd.ts new file mode 100644 index 00000000..b5fbfc5f --- /dev/null +++ b/backend/src/plugins/ModActions/commands/addcase/AddCaseSlashCmd.ts @@ -0,0 +1,65 @@ +import { slashOptions } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes"; +import { hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions"; +import { actualAddCaseCmd } from "../../functions/actualCommands/actualAddCaseCmd"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to add this case as", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason of the case", + }), +]; + +export const AddCaseSlashCmd = { + name: "addcase", + configPermission: "can_addcase", + description: "Add an arbitrary case to the specified user without taking any action", + allowDms: false, + + signature: [ + slashOptions.string({ + name: "type", + description: "The type of case to add", + required: true, + choices: Object.keys(CaseTypes).map((type) => ({ name: type, value: type })), + }), + slashOptions.user({ name: "user", description: "The user to add a case to", required: true }), + + ...opts, + ], + + async run({ interaction, options, pluginData }) { + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = interaction.member; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + sendErrorMessage(pluginData, interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = options.mod; + } + + actualAddCaseCmd( + pluginData, + interaction, + interaction.member, + mod, + attachments, + options.user, + options.type as keyof CaseTypes, + options.reason || "", + ); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/ban/BanMsgCmd.ts b/backend/src/plugins/ModActions/commands/ban/BanMsgCmd.ts new file mode 100644 index 00000000..efea59d7 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/ban/BanMsgCmd.ts @@ -0,0 +1,75 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { UserNotificationMethod, resolveUser } from "../../../../utils"; +import { actualBanCmd } from "../../functions/actualCommands/actualBanCmd"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs"; +import { modActionsMsgCmd } from "../../types"; + +const opts = { + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), + "delete-days": ct.number({ option: true, shortcut: "d" }), +}; + +export const BanMsgCmd = modActionsMsgCmd({ + trigger: "ban", + permission: "can_ban", + description: "Ban or Tempban 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.id) { + sendErrorMessage(pluginData, msg.channel, `User not found`); + 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 (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(args) ?? undefined; + } catch (e) { + sendErrorMessage(pluginData, msg.channel, e.message); + return; + } + + actualBanCmd( + pluginData, + msg.channel, + user, + args["time"] ? args["time"] : null, + args.reason || "", + [...msg.attachments.values()], + msg.member, + mod, + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/ban/BanSlashCmd.ts b/backend/src/plugins/ModActions/commands/ban/BanSlashCmd.ts new file mode 100644 index 00000000..acd19a43 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/ban/BanSlashCmd.ts @@ -0,0 +1,98 @@ +import { ChannelType } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { UserNotificationMethod, convertDelayStringToMS } from "../../../../utils"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions"; +import { actualBanCmd } from "../../functions/actualCommands/actualBanCmd"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants"; + +const opts = [ + slashOptions.string({ name: "time", description: "The duration of the ban", required: false }), + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to ban as", required: false }), + slashOptions.string({ + name: "notify", + description: "How to notify", + required: false, + choices: [ + { name: "DM", value: "dm" }, + { name: "Channel", value: "channel" }, + ], + }), + slashOptions.channel({ + name: "notify-channel", + description: "The channel to notify in", + channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], + required: false, + }), + slashOptions.number({ + name: "delete-days", + description: "The number of days of messages to delete", + required: false, + }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason of the ban", + }), +]; + +export const BanSlashCmd = { + name: "ban", + configPermission: "can_ban", + description: "Ban or Tempban the specified member", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to ban", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + sendErrorMessage(pluginData, interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + sendErrorMessage(pluginData, interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = options.mod; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + sendErrorMessage(pluginData, interaction, e.message); + return; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) : null; + if (options.time && !convertedTime) { + sendErrorMessage(pluginData, interaction, `Could not convert ${options.time} to a delay`); + return; + } + + actualBanCmd( + pluginData, + interaction, + options.user, + convertedTime, + options.reason || "", + attachments, + interaction.member, + mod, + contactMethods, + ); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/case/CaseMsgCmd.ts b/backend/src/plugins/ModActions/commands/case/CaseMsgCmd.ts new file mode 100644 index 00000000..6521e327 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/case/CaseMsgCmd.ts @@ -0,0 +1,19 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { actualCaseCmd } from "../../functions/actualCommands/actualCaseCmd"; +import { modActionsMsgCmd } from "../../types"; + +export const CaseMsgCmd = modActionsMsgCmd({ + trigger: "case", + permission: "can_view", + description: "Show information about a specific case", + + signature: [ + { + caseNumber: ct.number(), + }, + ], + + async run({ pluginData, message: msg, args }) { + actualCaseCmd(pluginData, msg.channel, msg.author.id, args.caseNumber); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/case/CaseSlashCmd.ts b/backend/src/plugins/ModActions/commands/case/CaseSlashCmd.ts new file mode 100644 index 00000000..c537ec6e --- /dev/null +++ b/backend/src/plugins/ModActions/commands/case/CaseSlashCmd.ts @@ -0,0 +1,17 @@ +import { slashOptions } from "knub"; +import { actualCaseCmd } from "../../functions/actualCommands/actualCaseCmd"; + +export const CaseSlashCmd = { + name: "case", + configPermission: "can_view", + description: "Show information about a specific case", + allowDms: false, + + signature: [ + slashOptions.number({ name: "case-number", description: "The number of the case to show", required: true }), + ], + + async run({ interaction, options, pluginData }) { + actualCaseCmd(pluginData, interaction, interaction.user.id, options["case-number"]); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/cases/CasesModMsgCmd.ts b/backend/src/plugins/ModActions/commands/cases/CasesModMsgCmd.ts new file mode 100644 index 00000000..b9e496b7 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/cases/CasesModMsgCmd.ts @@ -0,0 +1,47 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { actualCasesCmd } from "../../functions/actualCommands/actualCasesCmd"; +import { modActionsMsgCmd } from "../../types"; + +const opts = { + mod: ct.userId({ option: true }), + expand: ct.bool({ option: true, isSwitch: true, shortcut: "e" }), + hidden: ct.bool({ option: true, isSwitch: true, shortcut: "h" }), + reverseFilters: ct.switchOption({ def: false, shortcut: "r" }), + notes: ct.switchOption({ def: false, shortcut: "n" }), + warns: ct.switchOption({ def: false, shortcut: "w" }), + mutes: ct.switchOption({ def: false, shortcut: "m" }), + unmutes: ct.switchOption({ def: false, shortcut: "um" }), + bans: ct.switchOption({ def: false, shortcut: "b" }), + unbans: ct.switchOption({ def: false, shortcut: "ub" }), +}; + +export const CasesModMsgCmd = modActionsMsgCmd({ + trigger: ["cases", "modlogs", "infractions"], + permission: "can_view", + description: "Show the most recent 5 cases by the specified -mod", + + signature: [ + { + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + return actualCasesCmd( + pluginData, + msg.channel, + args.mod, + null, + msg.author, + args.notes, + args.warns, + args.mutes, + args.unmutes, + args.bans, + args.unbans, + args.reverseFilters, + args.hidden, + args.expand, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/cases/CasesSlashCmd.ts b/backend/src/plugins/ModActions/commands/cases/CasesSlashCmd.ts new file mode 100644 index 00000000..5207d76f --- /dev/null +++ b/backend/src/plugins/ModActions/commands/cases/CasesSlashCmd.ts @@ -0,0 +1,48 @@ +import { slashOptions } from "knub"; +import { actualCasesCmd } from "../../functions/actualCommands/actualCasesCmd"; + +const opts = [ + slashOptions.user({ name: "user", description: "The user to show cases for", required: false }), + slashOptions.user({ name: "mod", description: "The mod to filter cases by", required: false }), + slashOptions.boolean({ name: "expand", description: "Show each case individually", required: false }), + slashOptions.boolean({ name: "hidden", description: "Whether or not to show hidden cases", required: false }), + slashOptions.boolean({ + name: "reverse-filters", + description: "To treat case type filters as exclusive instead of inclusive", + required: false, + }), + slashOptions.boolean({ name: "notes", description: "To filter notes", required: false }), + slashOptions.boolean({ name: "warns", description: "To filter warns", required: false }), + slashOptions.boolean({ name: "mutes", description: "To filter mutes", required: false }), + slashOptions.boolean({ name: "unmutes", description: "To filter unmutes", required: false }), + slashOptions.boolean({ name: "bans", description: "To filter bans", required: false }), + slashOptions.boolean({ name: "unbans", description: "To filter unbans", required: false }), +]; + +export const CasesSlashCmd = { + name: "cases", + configPermission: "can_view", + description: "Show a list of cases the specified user has or the specified mod made", + allowDms: false, + + signature: [...opts], + + async run({ interaction, options, pluginData }) { + return actualCasesCmd( + pluginData, + interaction, + options.mod, + options.user, + interaction.user, + options.notes, + options.warns, + options.mutes, + options.unmutes, + options.bans, + options.unbans, + options["reverse-filters"], + options.hidden, + options.expand, + ); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/cases/CasesUserMsgCmd.ts b/backend/src/plugins/ModActions/commands/cases/CasesUserMsgCmd.ts new file mode 100644 index 00000000..765524df --- /dev/null +++ b/backend/src/plugins/ModActions/commands/cases/CasesUserMsgCmd.ts @@ -0,0 +1,57 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { sendErrorMessage } from "../../../../pluginUtils"; +import { resolveUser } from "../../../../utils"; +import { actualCasesCmd } from "../../functions/actualCommands/actualCasesCmd"; +import { modActionsMsgCmd } from "../../types"; + +const opts = { + mod: ct.userId({ option: true }), + expand: ct.bool({ option: true, isSwitch: true, shortcut: "e" }), + hidden: ct.bool({ option: true, isSwitch: true, shortcut: "h" }), + reverseFilters: ct.switchOption({ def: false, shortcut: "r" }), + notes: ct.switchOption({ def: false, shortcut: "n" }), + warns: ct.switchOption({ def: false, shortcut: "w" }), + mutes: ct.switchOption({ def: false, shortcut: "m" }), + unmutes: ct.switchOption({ def: false, shortcut: "um" }), + bans: ct.switchOption({ def: false, shortcut: "b" }), + unbans: ct.switchOption({ def: false, shortcut: "ub" }), +}; + +export const CasesUserMsgCmd = modActionsMsgCmd({ + trigger: ["cases", "modlogs", "infractions"], + 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.id) { + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; + } + + return actualCasesCmd( + pluginData, + msg.channel, + args.mod, + user, + msg.author, + args.notes, + args.warns, + args.mutes, + args.unmutes, + args.bans, + args.unbans, + args.reverseFilters, + args.hidden, + args.expand, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/constants.ts b/backend/src/plugins/ModActions/commands/constants.ts new file mode 100644 index 00000000..624f9de3 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/constants.ts @@ -0,0 +1,2 @@ +export const NUMBER_ATTACHMENTS_CASE_CREATION = 1; +export const NUMBER_ATTACHMENTS_CASE_UPDATE = 3; diff --git a/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseMsgCmd.ts b/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseMsgCmd.ts new file mode 100644 index 00000000..70dbc92f --- /dev/null +++ b/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseMsgCmd.ts @@ -0,0 +1,23 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { trimLines } from "../../../../utils"; +import { actualDeleteCaseCmd } from "../../functions/actualCommands/actualDeleteCaseCmd"; +import { modActionsMsgCmd } from "../../types"; + +export const DeleteCaseMsgCmd = modActionsMsgCmd({ + trigger: ["delete_case", "deletecase"], + permission: "can_deletecase", + description: trimLines(` + Delete the specified case. This operation can *not* be reversed. + It is generally recommended to use \`!hidecase\` instead when possible. + `), + + signature: { + caseNumber: ct.number({ rest: true }), + + force: ct.switchOption({ def: false, shortcut: "f" }), + }, + + async run({ pluginData, message, args }) { + actualDeleteCaseCmd(pluginData, message.channel, message.member, args.caseNumber, args.force); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseSlashCmd.ts b/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseSlashCmd.ts new file mode 100644 index 00000000..e087e3a4 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseSlashCmd.ts @@ -0,0 +1,27 @@ +import { slashOptions } from "knub"; +import { actualDeleteCaseCmd } from "../../functions/actualCommands/actualDeleteCaseCmd"; + +const opts = [slashOptions.boolean({ name: "force", description: "Whether or not to force delete", required: false })]; + +export const DeleteCaseSlashCmd = { + name: "deletecase", + configPermission: "can_deletecase", + description: "Delete the specified case. This operation can *not* be reversed.", + allowDms: false, + + signature: [ + slashOptions.string({ name: "case-number", description: "The number of the case to delete", required: true }), + + ...opts, + ], + + async run({ interaction, options, pluginData }) { + actualDeleteCaseCmd( + pluginData, + interaction, + interaction.member, + options["case-number"].split(/[\s,]+/), + !!options.force, + ); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/forceban/ForceBanMsgCmd.ts b/backend/src/plugins/ModActions/commands/forceban/ForceBanMsgCmd.ts new file mode 100644 index 00000000..ee0e4fa7 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forceban/ForceBanMsgCmd.ts @@ -0,0 +1,60 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { canActOn, hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { resolveMember, resolveUser } from "../../../../utils"; +import { actualForceBanCmd } from "../../functions/actualCommands/actualForceBanCmd"; +import { isBanned } from "../../functions/isBanned"; +import { modActionsMsgCmd } from "../../types"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const ForceBanMsgCmd = modActionsMsgCmd({ + 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.id) { + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; + } + + // 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 (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + } + + actualForceBanCmd(pluginData, msg.channel, msg.author.id, user, args.reason, [...msg.attachments.values()], mod); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/forceban/ForceBanSlashCmd.ts b/backend/src/plugins/ModActions/commands/forceban/ForceBanSlashCmd.ts new file mode 100644 index 00000000..c1722fcc --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forceban/ForceBanSlashCmd.ts @@ -0,0 +1,57 @@ +import { slashOptions } from "knub"; +import { hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { convertDelayStringToMS } from "../../../../utils"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions"; +import { actualForceBanCmd } from "../../functions/actualCommands/actualForceBanCmd"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to ban as", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason of the ban", + }), +]; + +export const ForceBanSlashCmd = { + name: "forceban", + configPermission: "can_ban", + description: "Force-ban the specified user, even if they aren't on the server", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to ban", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + sendErrorMessage(pluginData, interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + sendErrorMessage(pluginData, interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = options.mod; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) : null; + if (options.time && !convertedTime) { + sendErrorMessage(pluginData, interaction, `Could not convert ${options.time} to a delay`); + return; + } + + actualForceBanCmd(pluginData, interaction, interaction.user.id, options.user, options.reason, attachments, mod); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/forcemute/ForceMuteMsgCmd.ts b/backend/src/plugins/ModActions/commands/forcemute/ForceMuteMsgCmd.ts new file mode 100644 index 00000000..9ecbae61 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forcemute/ForceMuteMsgCmd.ts @@ -0,0 +1,84 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { canActOn, hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { resolveMember, resolveUser } from "../../../../utils"; +import { actualMuteCmd } from "../../functions/actualCommands/actualMuteCmd"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs"; +import { modActionsMsgCmd } from "../../types"; + +const opts = { + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), +}; + +export const ForceMuteMsgCmd = modActionsMsgCmd({ + 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.id) { + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; + } + + 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; + } + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = msg.member; + let ppId: string | undefined; + + if (args.mod) { + if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + ppId = msg.author.id; + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, e.message); + return; + } + + actualMuteCmd( + pluginData, + msg.channel, + user, + [...msg.attachments.values()], + mod, + ppId, + "time" in args ? args.time ?? undefined : undefined, + args.reason, + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/forcemute/ForceMuteSlashCmd.ts b/backend/src/plugins/ModActions/commands/forcemute/ForceMuteSlashCmd.ts new file mode 100644 index 00000000..b32cde4f --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forcemute/ForceMuteSlashCmd.ts @@ -0,0 +1,95 @@ +import { ChannelType } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { UserNotificationMethod, convertDelayStringToMS } from "../../../../utils"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions"; +import { actualMuteCmd } from "../../functions/actualCommands/actualMuteCmd"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants"; + +const opts = [ + slashOptions.string({ name: "time", description: "The duration of the mute", required: false }), + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to mute as", required: false }), + slashOptions.string({ + name: "notify", + description: "How to notify", + required: false, + choices: [ + { name: "DM", value: "dm" }, + { name: "Channel", value: "channel" }, + ], + }), + slashOptions.channel({ + name: "notify-channel", + description: "The channel to notify in", + channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], + required: false, + }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason of the mute", + }), +]; + +export const ForceMuteSlashCmd = { + name: "forcemute", + configPermission: "can_mute", + description: "Force-mute the specified user, even if they're not on the server", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to mute", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + sendErrorMessage(pluginData, interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member; + let ppId: string | undefined; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + sendErrorMessage(pluginData, interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = options.mod; + ppId = interaction.user.id; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) : null; + if (options.time && !convertedTime) { + sendErrorMessage(pluginData, interaction, `Could not convert ${options.time} to a delay`); + return; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + sendErrorMessage(pluginData, interaction, e.message); + return; + } + + actualMuteCmd( + pluginData, + interaction, + options.user, + attachments, + mod, + ppId, + options.time, + options.reason, + contactMethods, + ); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts b/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteMsgCmd.ts similarity index 55% rename from backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts rename to backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteMsgCmd.ts index 5ce489a1..c1b97351 100644 --- a/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts +++ b/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteMsgCmd.ts @@ -1,14 +1,14 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils"; -import { resolveMember, resolveUser } from "../../../utils"; -import { actualUnmuteCmd } from "../functions/actualUnmuteUserCmd"; -import { modActionsMsgCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { canActOn, hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { resolveMember, resolveUser } from "../../../../utils"; +import { actualUnmuteCmd } from "../../functions/actualCommands/actualUnmuteCmd"; +import { modActionsMsgCmd } from "../../types"; const opts = { mod: ct.member({ option: true }), }; -export const ForceUnmuteCmd = modActionsMsgCmd({ +export const ForceUnmuteMsgCmd = modActionsMsgCmd({ trigger: "forceunmute", permission: "can_mute", description: "Force-unmute the specified user, even if they're not on the server", @@ -51,6 +51,29 @@ export const ForceUnmuteCmd = modActionsMsgCmd({ return; } - actualUnmuteCmd(pluginData, user, msg, args); + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = msg.member; + let ppId: string | undefined; + + if (args.mod) { + if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + ppId = msg.author.id; + } + + actualUnmuteCmd( + pluginData, + msg.channel, + user, + [...msg.attachments.values()], + mod, + ppId, + "time" in args ? args.time ?? undefined : undefined, + args.reason, + ); }, }); diff --git a/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteSlashCmd.ts b/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteSlashCmd.ts new file mode 100644 index 00000000..107151a8 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteSlashCmd.ts @@ -0,0 +1,60 @@ +import { slashOptions } from "knub"; +import { hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { convertDelayStringToMS } from "../../../../utils"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions"; +import { actualUnmuteCmd } from "../../functions/actualCommands/actualUnmuteCmd"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants"; + +const opts = [ + slashOptions.string({ name: "time", description: "The duration of the unmute", required: false }), + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to unmute as", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason of the unmute", + }), +]; + +export const ForceUnmuteSlashCmd = { + name: "forceunmute", + configPermission: "can_mute", + description: "Force-unmute the specified user, even if they're not on the server", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to unmute", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + sendErrorMessage(pluginData, interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member; + let ppId: string | undefined; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + sendErrorMessage(pluginData, interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = options.mod; + ppId = interaction.user.id; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) : null; + if (options.time && !convertedTime) { + sendErrorMessage(pluginData, interaction, `Could not convert ${options.time} to a delay`); + return; + } + + actualUnmuteCmd(pluginData, interaction, options.user, attachments, mod, ppId, options.time, options.reason); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/hidecase/HideCaseMsgCmd.ts b/backend/src/plugins/ModActions/commands/hidecase/HideCaseMsgCmd.ts new file mode 100644 index 00000000..e7701d4a --- /dev/null +++ b/backend/src/plugins/ModActions/commands/hidecase/HideCaseMsgCmd.ts @@ -0,0 +1,19 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { actualHideCaseCmd } from "../../functions/actualCommands/actualHideCaseCmd"; +import { modActionsMsgCmd } from "../../types"; + +export const HideCaseMsgCmd = modActionsMsgCmd({ + 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({ rest: true }), + }, + ], + + async run({ pluginData, message: msg, args }) { + actualHideCaseCmd(pluginData, msg.channel, args.caseNum); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/hidecase/HideCaseSlashCmd.ts b/backend/src/plugins/ModActions/commands/hidecase/HideCaseSlashCmd.ts new file mode 100644 index 00000000..c324b250 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/hidecase/HideCaseSlashCmd.ts @@ -0,0 +1,17 @@ +import { slashOptions } from "knub"; +import { actualHideCaseCmd } from "../../functions/actualCommands/actualHideCaseCmd"; + +export const HideCaseSlashCmd = { + name: "hidecase", + configPermission: "can_hidecase", + description: "Hide the specified case so it doesn't appear in !cases or !info", + allowDms: false, + + signature: [ + slashOptions.string({ name: "case-number", description: "The number of the case to hide", required: true }), + ], + + async run({ interaction, options, pluginData }) { + actualHideCaseCmd(pluginData, interaction, options["case-number"].split(/[\s,]+/).map(Number)); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/kick/KickMsgCmd.ts b/backend/src/plugins/ModActions/commands/kick/KickMsgCmd.ts new file mode 100644 index 00000000..bab6064d --- /dev/null +++ b/backend/src/plugins/ModActions/commands/kick/KickMsgCmd.ts @@ -0,0 +1,68 @@ +import { hasPermission } from "knub/helpers"; +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { sendErrorMessage } from "../../../../pluginUtils"; +import { resolveUser } from "../../../../utils"; +import { actualKickCmd } from "../../functions/actualCommands/actualKickCmd"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs"; +import { modActionsMsgCmd } from "../../types"; + +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 KickMsgCmd = modActionsMsgCmd({ + 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 }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + sendErrorMessage(pluginData, msg.channel, `User not found`); + 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 (!(await hasPermission(await pluginData.config.getForMessage(msg), "can_act_as_other"))) { + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, e.message); + return; + } + + actualKickCmd( + pluginData, + msg.channel, + msg.member, + user, + args.reason, + [...msg.attachments.values()], + mod, + contactMethods, + args.clean, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/kick/KickSlashCmd.ts b/backend/src/plugins/ModActions/commands/kick/KickSlashCmd.ts new file mode 100644 index 00000000..46041006 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/kick/KickSlashCmd.ts @@ -0,0 +1,91 @@ +import { ChannelType } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { UserNotificationMethod } from "../../../../utils"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions"; +import { actualKickCmd } from "../../functions/actualCommands/actualKickCmd"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to kick as", required: false }), + slashOptions.string({ + name: "notify", + description: "How to notify", + required: false, + choices: [ + { name: "DM", value: "dm" }, + { name: "Channel", value: "channel" }, + ], + }), + slashOptions.channel({ + name: "notify-channel", + description: "The channel to notify in", + channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], + required: false, + }), + slashOptions.boolean({ + name: "clean", + description: "Whether or not to delete the member's last messages", + required: false, + }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason of the kick", + }), +]; + +export const KickSlashCmd = { + name: "kick", + configPermission: "can_kick", + description: "Kick the specified member", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to kick", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + sendErrorMessage(pluginData, interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + sendErrorMessage(pluginData, interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = options.mod; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + sendErrorMessage(pluginData, interaction, e.message); + return; + } + + actualKickCmd( + pluginData, + interaction, + interaction.member, + options.user, + options.reason || "", + attachments, + mod, + contactMethods, + options.clean, + ); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/massban/MassBanMsgCmd.ts b/backend/src/plugins/ModActions/commands/massban/MassBanMsgCmd.ts new file mode 100644 index 00000000..222dbacc --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massban/MassBanMsgCmd.ts @@ -0,0 +1,19 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { actualMassBanCmd } from "../../functions/actualCommands/actualMassBanCmd"; +import { modActionsMsgCmd } from "../../types"; + +export const MassBanMsgCmd = modActionsMsgCmd({ + 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 }) { + actualMassBanCmd(pluginData, msg.channel, args.userIds, msg.member); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/massban/MassBanSlashCmd.ts b/backend/src/plugins/ModActions/commands/massban/MassBanSlashCmd.ts new file mode 100644 index 00000000..74f9ba91 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massban/MassBanSlashCmd.ts @@ -0,0 +1,15 @@ +import { slashOptions } from "knub"; +import { actualMassBanCmd } from "../../functions/actualCommands/actualMassBanCmd"; + +export const MassBanSlashCmd = { + name: "massban", + configPermission: "can_massban", + description: "Mass-ban a list of user IDs", + allowDms: false, + + signature: [slashOptions.string({ name: "user-ids", description: "The list of user IDs to ban", required: true })], + + async run({ interaction, options, pluginData }) { + actualMassBanCmd(pluginData, interaction, options["user-ids"].split(/[\s,\r\n]+/), interaction.member); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/massmute/MassMuteMsgCmd.ts b/backend/src/plugins/ModActions/commands/massmute/MassMuteMsgCmd.ts new file mode 100644 index 00000000..0d7793ce --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massmute/MassMuteMsgCmd.ts @@ -0,0 +1,19 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { actualMassMuteCmd } from "../../functions/actualCommands/actualMassMuteCmd"; +import { modActionsMsgCmd } from "../../types"; + +export const MassMuteMsgCmd = modActionsMsgCmd({ + trigger: "massmute", + permission: "can_massmute", + description: "Mass-mute a list of user IDs", + + signature: [ + { + userIds: ct.string({ rest: true }), + }, + ], + + async run({ pluginData, message: msg, args }) { + actualMassMuteCmd(pluginData, msg.channel, args.userIds, msg.member); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/massmute/MassMuteSlashCmd.ts b/backend/src/plugins/ModActions/commands/massmute/MassMuteSlashCmd.ts new file mode 100644 index 00000000..1650b174 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massmute/MassMuteSlashCmd.ts @@ -0,0 +1,15 @@ +import { slashOptions } from "knub"; +import { actualMassMuteCmd } from "../../functions/actualCommands/actualMassMuteCmd"; + +export const MassMuteSlashSlashCmd = { + name: "massmute", + configPermission: "can_massmute", + description: "Mass-mute a list of user IDs", + allowDms: false, + + signature: [slashOptions.string({ name: "user-ids", description: "The list of user IDs to mute", required: true })], + + async run({ interaction, options, pluginData }) { + actualMassMuteCmd(pluginData, interaction, options["user-ids"].split(/[\s,\r\n]+/), interaction.member); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/massunban/MassUnbanMsgCmd.ts b/backend/src/plugins/ModActions/commands/massunban/MassUnbanMsgCmd.ts new file mode 100644 index 00000000..8508b00e --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massunban/MassUnbanMsgCmd.ts @@ -0,0 +1,19 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { actualMassBanCmd } from "../../functions/actualCommands/actualMassBanCmd"; +import { modActionsMsgCmd } from "../../types"; + +export const MassUnbanMsgCmd = modActionsMsgCmd({ + trigger: "massunban", + permission: "can_massunban", + description: "Mass-unban a list of user IDs", + + signature: [ + { + userIds: ct.string({ rest: true }), + }, + ], + + async run({ pluginData, message: msg, args }) { + actualMassBanCmd(pluginData, msg.channel, args.userIds, msg.member); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/massunban/MassUnbanSlashCmd.ts b/backend/src/plugins/ModActions/commands/massunban/MassUnbanSlashCmd.ts new file mode 100644 index 00000000..15f6ca6a --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massunban/MassUnbanSlashCmd.ts @@ -0,0 +1,15 @@ +import { slashOptions } from "knub"; +import { actualMassUnbanCmd } from "../../functions/actualCommands/actualMassUnbanCmd"; + +export const MassUnbanSlashCmd = { + name: "massunban", + configPermission: "can_massunban", + description: "Mass-unban a list of user IDs", + allowDms: false, + + signature: [slashOptions.string({ name: "user-ids", description: "The list of user IDs to unban", required: true })], + + async run({ interaction, options, pluginData }) { + actualMassUnbanCmd(pluginData, interaction, options["user-ids"].split(/[\s,\r\n]+/), interaction.member); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/MuteCmd.ts b/backend/src/plugins/ModActions/commands/mute/MuteMsgCmd.ts similarity index 56% rename from backend/src/plugins/ModActions/commands/MuteCmd.ts rename to backend/src/plugins/ModActions/commands/mute/MuteMsgCmd.ts index 4c505701..16a5343f 100644 --- a/backend/src/plugins/ModActions/commands/MuteCmd.ts +++ b/backend/src/plugins/ModActions/commands/mute/MuteMsgCmd.ts @@ -1,10 +1,11 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils"; -import { resolveMember, resolveUser } from "../../../utils"; -import { waitForButtonConfirm } from "../../../utils/waitForInteraction"; -import { actualMuteUserCmd } from "../functions/actualMuteUserCmd"; -import { isBanned } from "../functions/isBanned"; -import { modActionsMsgCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { canActOn, hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { resolveMember, resolveUser } from "../../../../utils"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction"; +import { actualMuteCmd } from "../../functions/actualCommands/actualMuteCmd"; +import { isBanned } from "../../functions/isBanned"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs"; +import { modActionsMsgCmd } from "../../types"; const opts = { mod: ct.member({ option: true }), @@ -12,7 +13,7 @@ const opts = { "notify-channel": ct.textChannel({ option: true }), }; -export const MuteCmd = modActionsMsgCmd({ +export const MuteMsgCmd = modActionsMsgCmd({ trigger: "mute", permission: "can_mute", description: "Mute the specified member", @@ -73,6 +74,38 @@ export const MuteCmd = modActionsMsgCmd({ return; } - actualMuteUserCmd(pluginData, user, msg, args); + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = msg.member; + let ppId: string | undefined; + + if (args.mod) { + if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + ppId = msg.author.id; + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, e.message); + return; + } + + actualMuteCmd( + pluginData, + msg.channel, + user, + [...msg.attachments.values()], + mod, + ppId, + "time" in args ? args.time ?? undefined : undefined, + args.reason, + contactMethods, + ); }, }); diff --git a/backend/src/plugins/ModActions/commands/mute/MuteSlashCmd.ts b/backend/src/plugins/ModActions/commands/mute/MuteSlashCmd.ts new file mode 100644 index 00000000..c17200fc --- /dev/null +++ b/backend/src/plugins/ModActions/commands/mute/MuteSlashCmd.ts @@ -0,0 +1,130 @@ +import { ChannelType } from "discord.js"; +import { slashOptions } from "knub"; +import { canActOn, hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { UserNotificationMethod, convertDelayStringToMS, resolveMember } from "../../../../utils"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction"; +import { actualMuteCmd } from "../../functions/actualCommands/actualMuteCmd"; +import { isBanned } from "../../functions/isBanned"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants"; + +const opts = [ + slashOptions.string({ name: "time", description: "The duration of the mute", required: false }), + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to mute as", required: false }), + slashOptions.string({ + name: "notify", + description: "How to notify", + required: false, + choices: [ + { name: "DM", value: "dm" }, + { name: "Channel", value: "channel" }, + ], + }), + slashOptions.channel({ + name: "notify-channel", + description: "The channel to notify in", + channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], + required: false, + }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason of the mute", + }), +]; + +export const MuteSlashCmd = { + name: "mute", + configPermission: "can_mute", + description: "Mute the specified member", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to mute", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + sendErrorMessage(pluginData, interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + const memberToMute = await resolveMember(pluginData.client, pluginData.guild, options.user.id); + + if (!memberToMute) { + const _isBanned = await isBanned(pluginData, options.user.id); + const prefix = pluginData.fullConfig.prefix; + if (_isBanned) { + sendErrorMessage( + pluginData, + interaction, + `User is banned. Use \`${prefix}forcemute\` if you want to mute them anyway.`, + ); + return; + } else { + // Ask the mod if we should upgrade to a forcemute as the user is not on the server + const reply = await waitForButtonConfirm( + interaction, + { content: "User not found on the server, forcemute instead?" }, + { confirmText: "Yes", cancelText: "No", restrictToId: interaction.member.id }, + ); + + if (!reply) { + sendErrorMessage(pluginData, interaction, "User not on server, mute cancelled by moderator"); + return; + } + } + } + + // Make sure we're allowed to mute this member + if (memberToMute && !canActOn(pluginData, interaction.member, memberToMute)) { + sendErrorMessage(pluginData, interaction, "Cannot mute: insufficient permissions"); + return; + } + + let mod = interaction.member; + let ppId: string | undefined; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + sendErrorMessage(pluginData, interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = options.mod; + ppId = interaction.user.id; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) : null; + if (options.time && !convertedTime) { + sendErrorMessage(pluginData, interaction, `Could not convert ${options.time} to a delay`); + return; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + sendErrorMessage(pluginData, interaction, e.message); + return; + } + + actualMuteCmd( + pluginData, + interaction, + options.user, + attachments, + mod, + ppId, + options.time, + options.reason, + contactMethods, + ); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/note/NoteMsgCmd.ts b/backend/src/plugins/ModActions/commands/note/NoteMsgCmd.ts index c7122fe9..14336d89 100644 --- a/backend/src/plugins/ModActions/commands/note/NoteMsgCmd.ts +++ b/backend/src/plugins/ModActions/commands/note/NoteMsgCmd.ts @@ -1,7 +1,7 @@ import { commandTypeHelpers as ct } from "../../../../commandTypes"; import { sendErrorMessage } from "../../../../pluginUtils"; import { resolveUser } from "../../../../utils"; -import { actualNoteCmd } from "../../functions/actualNoteCmd"; +import { actualNoteCmd } from "../../functions/actualCommands/actualNoteCmd"; import { modActionsMsgCmd } from "../../types"; export const NoteMsgCmd = modActionsMsgCmd({ diff --git a/backend/src/plugins/ModActions/commands/note/NoteSlashCmd.ts b/backend/src/plugins/ModActions/commands/note/NoteSlashCmd.ts index 82b38be7..50fd2735 100644 --- a/backend/src/plugins/ModActions/commands/note/NoteSlashCmd.ts +++ b/backend/src/plugins/ModActions/commands/note/NoteSlashCmd.ts @@ -1,38 +1,27 @@ -import { ApplicationCommandOptionType, ChatInputCommandInteraction } from "discord.js"; import { slashOptions } from "knub"; import { sendErrorMessage } from "../../../../pluginUtils"; -import { actualNoteCmd } from "../../functions/actualNoteCmd"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions"; +import { actualNoteCmd } from "../../functions/actualCommands/actualNoteCmd"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants"; + +const opts = [ + slashOptions.string({ name: "note", description: "The note to add to the user", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the note", + }), +]; export const NoteSlashCmd = { name: "note", + configPermission: "can_note", description: "Add a note to the specified user", allowDms: false, - configPermission: "can_note", - signature: [ - slashOptions.user({ name: "user", description: "The user to add a note to", required: true }), - slashOptions.string({ name: "note", description: "The note to add to the user", required: false }), - ...new Array(10).fill(0).map((_, i) => { - return { - name: `attachment${i + 1}`, - description: "An attachment to add to the note", - type: ApplicationCommandOptionType.Attachment, - required: false, - resolveValue: (interaction: ChatInputCommandInteraction) => { - return interaction.options.getAttachment(`attachment${i + 1}`); - }, - getExtraAPIProps: () => ({}), - }; - }), - ], + signature: [slashOptions.user({ name: "user", description: "The user to add a note to", required: true }), ...opts], async run({ interaction, options, pluginData }) { - const attachments = new Array(10) - .fill(0) - .map((_, i) => { - return options[`attachment${i + 1}`]; - }) - .filter((a) => a); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); if ((!options.note || options.note.trim() === "") && attachments.length < 1) { sendErrorMessage(pluginData, interaction, "Text or attachment required", undefined, undefined, true); diff --git a/backend/src/plugins/ModActions/commands/unban/UnbanMsgCmd.ts b/backend/src/plugins/ModActions/commands/unban/UnbanMsgCmd.ts new file mode 100644 index 00000000..1d9bd37d --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unban/UnbanMsgCmd.ts @@ -0,0 +1,45 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { resolveUser } from "../../../../utils"; +import { actualUnbanCmd } from "../../functions/actualCommands/actualUnbanCmd"; +import { modActionsMsgCmd } from "../../types"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const UnbanMsgCmd = modActionsMsgCmd({ + trigger: "unban", + permission: "can_unban", + 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.id) { + sendErrorMessage(pluginData, msg.channel, `User not found`); + 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 (!(await hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id }))) { + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + } + + actualUnbanCmd(pluginData, msg.channel, msg.author.id, user, args.reason, [...msg.attachments.values()], mod); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/unban/UnbanSlashCmd.ts b/backend/src/plugins/ModActions/commands/unban/UnbanSlashCmd.ts new file mode 100644 index 00000000..e85cfdfb --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unban/UnbanSlashCmd.ts @@ -0,0 +1,50 @@ +import { slashOptions } from "knub"; +import { hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions"; +import { actualUnbanCmd } from "../../functions/actualCommands/actualUnbanCmd"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to unban as", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason of the unban", + }), +]; + +export const UnbanSlashCmd = { + name: "unban", + configPermission: "can_unban", + description: "Unban the specified member", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to unban", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + sendErrorMessage(pluginData, interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + sendErrorMessage(pluginData, interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = options.mod; + } + + actualUnbanCmd(pluginData, interaction, interaction.user.id, options.user, options.reason, attachments, mod); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseMsgCmd.ts b/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseMsgCmd.ts new file mode 100644 index 00000000..94c029a2 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseMsgCmd.ts @@ -0,0 +1,19 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { actualHideCaseCmd } from "../../functions/actualCommands/actualHideCaseCmd"; +import { modActionsMsgCmd } from "../../types"; + +export const UnhideCaseMsgCmd = modActionsMsgCmd({ + 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({ rest: true }), + }, + ], + + async run({ pluginData, message: msg, args }) { + actualHideCaseCmd(pluginData, msg.channel, args.caseNum); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseSlashCmd.ts b/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseSlashCmd.ts new file mode 100644 index 00000000..d0a66d7c --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseSlashCmd.ts @@ -0,0 +1,17 @@ +import { slashOptions } from "knub"; +import { actualUnhideCaseCmd } from "../../functions/actualCommands/actualUnhideCaseCmd"; + +export const UnhideCaseSlashCmd = { + name: "unhidecase", + configPermission: "can_hidecase", + description: "Un-hide the specified case", + allowDms: false, + + signature: [ + slashOptions.string({ name: "case-number", description: "The number of the case to unhide", required: true }), + ], + + async run({ interaction, options, pluginData }) { + actualUnhideCaseCmd(pluginData, interaction, options["case-number"].split(/[\s,]+/).map(Number)); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts b/backend/src/plugins/ModActions/commands/unmute/UnmuteMsgCmd.ts similarity index 65% rename from backend/src/plugins/ModActions/commands/UnmuteCmd.ts rename to backend/src/plugins/ModActions/commands/unmute/UnmuteMsgCmd.ts index 3a4171ac..8d7e111a 100644 --- a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts +++ b/backend/src/plugins/ModActions/commands/unmute/UnmuteMsgCmd.ts @@ -1,17 +1,17 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils"; -import { resolveMember, resolveUser } from "../../../utils"; -import { waitForButtonConfirm } from "../../../utils/waitForInteraction"; -import { actualUnmuteCmd } from "../functions/actualUnmuteUserCmd"; -import { isBanned } from "../functions/isBanned"; -import { modActionsMsgCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { canActOn, hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { resolveMember, resolveUser } from "../../../../utils"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin"; +import { actualUnmuteCmd } from "../../functions/actualCommands/actualUnmuteCmd"; +import { isBanned } from "../../functions/isBanned"; +import { modActionsMsgCmd } from "../../types"; const opts = { mod: ct.member({ option: true }), }; -export const UnmuteCmd = modActionsMsgCmd({ +export const UnmuteMsgCmd = modActionsMsgCmd({ trigger: "unmute", permission: "can_mute", description: "Unmute the specified member", @@ -84,6 +84,29 @@ export const UnmuteCmd = modActionsMsgCmd({ return; } - actualUnmuteCmd(pluginData, user, msg, args); + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = msg.member; + let ppId: string | undefined; + + if (args.mod) { + if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + ppId = msg.author.id; + } + + actualUnmuteCmd( + pluginData, + msg.channel, + user, + [...msg.attachments.values()], + mod, + ppId, + "time" in args ? args.time ?? undefined : undefined, + args.reason, + ); }, }); diff --git a/backend/src/plugins/ModActions/commands/unmute/UnmuteSlashCmd.ts b/backend/src/plugins/ModActions/commands/unmute/UnmuteSlashCmd.ts new file mode 100644 index 00000000..04526be6 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unmute/UnmuteSlashCmd.ts @@ -0,0 +1,108 @@ +import { slashOptions } from "knub"; +import { canActOn, hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { convertDelayStringToMS, resolveMember } from "../../../../utils"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin"; +import { actualUnmuteCmd } from "../../functions/actualCommands/actualUnmuteCmd"; +import { isBanned } from "../../functions/isBanned"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants"; + +const opts = [ + slashOptions.string({ name: "time", description: "The duration of the unmute", required: false }), + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to unmute as", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason of the unmute", + }), +]; + +export const UnmuteSlashCmd = { + name: "unmute", + configPermission: "can_mute", + description: "Unmute the specified member", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to unmute", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + sendErrorMessage(pluginData, interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, options.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(options.user.id)) && + !hasMuteRole && + !memberToUnmute?.isCommunicationDisabled() + ) { + sendErrorMessage(pluginData, interaction, "Cannot unmute: member is not muted"); + return; + } + + if (!memberToUnmute) { + const banned = await isBanned(pluginData, options.user.id); + const prefix = pluginData.fullConfig.prefix; + if (banned) { + sendErrorMessage( + pluginData, + interaction, + `User is banned. Use \`${prefix}forceunmute\` to unmute them anyway.`, + ); + return; + } else { + // Ask the mod if we should upgrade to a forceunmute as the user is not on the server + const reply = await waitForButtonConfirm( + interaction, + { content: "User not on server, forceunmute instead?" }, + { confirmText: "Yes", cancelText: "No", restrictToId: interaction.user.id }, + ); + + if (!reply) { + sendErrorMessage(pluginData, interaction, "User not on server, unmute cancelled by moderator"); + return; + } + } + } + + // Make sure we're allowed to unmute this member + if (memberToUnmute && !canActOn(pluginData, interaction.member, memberToUnmute)) { + sendErrorMessage(pluginData, interaction, "Cannot unmute: insufficient permissions"); + return; + } + + let mod = interaction.member; + let ppId: string | undefined; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + sendErrorMessage(pluginData, interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = options.mod; + ppId = interaction.user.id; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) : null; + if (options.time && !convertedTime) { + sendErrorMessage(pluginData, interaction, `Could not convert ${options.time} to a delay`); + return; + } + + actualUnmuteCmd(pluginData, interaction, options.user, attachments, mod, ppId, options.time, options.reason); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/UpdateCmd.ts b/backend/src/plugins/ModActions/commands/update/UpdateMsgCmd.ts similarity index 57% rename from backend/src/plugins/ModActions/commands/UpdateCmd.ts rename to backend/src/plugins/ModActions/commands/update/UpdateMsgCmd.ts index 59c68b0d..73c74f36 100644 --- a/backend/src/plugins/ModActions/commands/UpdateCmd.ts +++ b/backend/src/plugins/ModActions/commands/update/UpdateMsgCmd.ts @@ -1,8 +1,8 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { updateCase } from "../functions/updateCase"; -import { modActionsMsgCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { updateCase } from "../../functions/updateCase"; +import { modActionsMsgCmd } from "../../types"; -export const UpdateCmd = modActionsMsgCmd({ +export const UpdateMsgCmd = modActionsMsgCmd({ trigger: ["update", "reason"], permission: "can_note", description: @@ -19,6 +19,6 @@ export const UpdateCmd = modActionsMsgCmd({ ], async run({ pluginData, message: msg, args }) { - await updateCase(pluginData, msg, args); + await updateCase(pluginData, msg.channel, msg.author, args.caseNumber, args.note, [...msg.attachments.values()]); }, }); diff --git a/backend/src/plugins/ModActions/commands/update/UpdateSlashCmd.ts b/backend/src/plugins/ModActions/commands/update/UpdateSlashCmd.ts new file mode 100644 index 00000000..4f6fdae2 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/update/UpdateSlashCmd.ts @@ -0,0 +1,33 @@ +import { slashOptions } from "knub"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions"; +import { updateCase } from "../../functions/updateCase"; +import { NUMBER_ATTACHMENTS_CASE_UPDATE } from "../constants"; + +const opts = [ + slashOptions.string({ name: "case-number", description: "The number of the case to update", required: false }), + slashOptions.string({ name: "reason", description: "The note to add to the case", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_UPDATE, { + name: "attachment", + description: "An attachment to add to the update", + }), +]; + +export const UpdateSlashCmd = { + name: "update", + configPermission: "can_note", + description: "Update the specified case (or your latest case) by adding more notes to it", + allowDms: false, + + signature: [...opts], + + async run({ interaction, options, pluginData }) { + await updateCase( + pluginData, + interaction, + interaction.user, + options.caseNumber, + options.note, + retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_UPDATE, options, "attachment"), + ); + }, +}; diff --git a/backend/src/plugins/ModActions/commands/warn/WarnMsgCmd.ts b/backend/src/plugins/ModActions/commands/warn/WarnMsgCmd.ts new file mode 100644 index 00000000..4baeb3c1 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/warn/WarnMsgCmd.ts @@ -0,0 +1,79 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes"; +import { canActOn, hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { errorMessage, resolveMember, resolveUser } from "../../../../utils"; +import { actualWarnCmd } from "../../functions/actualCommands/actualWarnCmd"; +import { isBanned } from "../../functions/isBanned"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs"; +import { modActionsMsgCmd } from "../../types"; + +export const WarnMsgCmd = modActionsMsgCmd({ + trigger: "warn", + permission: "can_warn", + description: "Send a warning to the specified user", + + signature: { + user: ct.string(), + reason: ct.string({ catchAll: true }), + + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), + }, + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + sendErrorMessage(pluginData, msg.channel, `User not found`); + return; + } + + const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, user.id); + + if (!memberToWarn) { + const _isBanned = await isBanned(pluginData, user.id); + if (_isBanned) { + 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 warn this member + if (!canActOn(pluginData, msg.member, memberToWarn)) { + sendErrorMessage(pluginData, msg.channel, "Cannot warn: 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 (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + msg.channel.send(errorMessage("You don't have permission to use -mod")); + return; + } + + mod = args.mod; + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, e.message); + return; + } + + actualWarnCmd( + pluginData, + msg.channel, + msg.author.id, + mod, + memberToWarn, + args.reason, + [...msg.attachments.values()], + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/warn/WarnSlashCmd.ts b/backend/src/plugins/ModActions/commands/warn/WarnSlashCmd.ts new file mode 100644 index 00000000..025f1981 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/warn/WarnSlashCmd.ts @@ -0,0 +1,105 @@ +import { ChannelType } from "discord.js"; +import { slashOptions } from "knub"; +import { canActOn, hasPermission, sendErrorMessage } from "../../../../pluginUtils"; +import { UserNotificationMethod, resolveMember } from "../../../../utils"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions"; +import { actualWarnCmd } from "../../functions/actualCommands/actualWarnCmd"; +import { isBanned } from "../../functions/isBanned"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to warn as", required: false }), + slashOptions.string({ + name: "notify", + description: "How to notify", + required: false, + choices: [ + { name: "DM", value: "dm" }, + { name: "Channel", value: "channel" }, + ], + }), + slashOptions.channel({ + name: "notify-channel", + description: "The channel to notify in", + channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], + required: false, + }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason of the warn", + }), +]; + +export const WarnSlashCmd = { + name: "warn", + configPermission: "can_warn", + description: "Send a warning to the specified user", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to warn", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + sendErrorMessage(pluginData, interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, options.user.id); + + if (!memberToWarn) { + const _isBanned = await isBanned(pluginData, options.user.id); + if (_isBanned) { + sendErrorMessage(pluginData, interaction, `User is banned`); + } else { + sendErrorMessage(pluginData, interaction, `User not found on the server`); + } + + return; + } + + // Make sure we're allowed to warn this member + if (!canActOn(pluginData, interaction.member, memberToWarn)) { + sendErrorMessage(pluginData, interaction, "Cannot warn: insufficient permissions"); + return; + } + + let mod = interaction.member; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + sendErrorMessage(pluginData, interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = options.mod; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + sendErrorMessage(pluginData, interaction, e.message); + return; + } + + actualWarnCmd( + pluginData, + interaction, + interaction.user.id, + mod, + memberToWarn, + options.reason ?? "", + attachments, + contactMethods, + ); + }, +}; diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualAddCaseCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualAddCaseCmd.ts new file mode 100644 index 00000000..572d450c --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualAddCaseCmd.ts @@ -0,0 +1,55 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, TextBasedChannel, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes"; +import { Case } from "../../../../data/entities/Case"; +import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils"; +import { UnknownUser, renderUserUsername, resolveMember } from "../../../../utils"; +import { CasesPlugin } from "../../../Cases/CasesPlugin"; +import { LogsPlugin } from "../../../Logs/LogsPlugin"; +import { ModActionsPluginType } from "../../types"; +import { formatReasonWithAttachments } from "../formatReasonWithAttachments"; + +export async function actualAddCaseCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + author: GuildMember, + mod: GuildMember, + attachments: Array, + user: User | UnknownUser, + type: keyof CaseTypes, + reason: string, +) { + // 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, author, member)) { + sendErrorMessage(pluginData, context, "Cannot add case on this user: insufficient permissions"); + return; + } + + const formattedReason = formatReasonWithAttachments(reason, 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: formattedReason, + ppId: mod.id !== author.id ? author.id : undefined, + }); + + if (user) { + sendSuccessMessage(pluginData, context, `Case #${theCase.case_number} created for **${renderUserUsername(user)}**`); + } else { + sendSuccessMessage(pluginData, context, `Case #${theCase.case_number} created`); + } + + // Log the action + pluginData.getPlugin(LogsPlugin).logCaseCreate({ + mod: mod.user, + userId: user.id, + caseNum: theCase.case_number, + caseType: type.toUpperCase(), + reason: formattedReason, + }); +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualBanCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualBanCmd.ts new file mode 100644 index 00000000..5b665884 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualBanCmd.ts @@ -0,0 +1,182 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, TextBasedChannel, User } from "discord.js"; +import humanizeDuration from "humanize-duration"; +import { GuildPluginData } from "knub"; +import { getMemberLevel } from "knub/helpers"; +import { CaseTypes } from "../../../../data/CaseTypes"; +import { clearExpiringTempban, registerExpiringTempban } from "../../../../data/loops/expiringTempbansLoop"; +import { canActOn, isContextInteraction, sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils"; +import { UnknownUser, UserNotificationMethod, renderUserUsername, resolveMember } from "../../../../utils"; +import { banLock } from "../../../../utils/lockNameHelpers"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction"; +import { CasesPlugin } from "../../../Cases/CasesPlugin"; +import { LogsPlugin } from "../../../Logs/LogsPlugin"; +import { ModActionsPluginType } from "../../types"; +import { banUserId } from "../banUserId"; +import { formatReasonWithAttachments } from "../formatReasonWithAttachments"; +import { isBanned } from "../isBanned"; + +export async function actualBanCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + user: User | UnknownUser, + time: number | null, + reason: string, + attachments: Attachment[], + author: GuildMember, + mod: GuildMember, + contactMethods?: UserNotificationMethod[], + deleteDays?: number, +) { + const memberToBan = await resolveMember(pluginData.client, pluginData.guild, user.id); + const formattedReason = formatReasonWithAttachments(reason, attachments); + + // acquire a lock because of the needed user-inputs below (if banned/not on server) + const lock = await pluginData.locks.acquire(banLock(user)); + let forceban = false; + const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); + + if (!memberToBan) { + const banned = await isBanned(pluginData, user.id); + + if (!banned) { + // Ask the mod if we should upgrade to a forceban as the user is not on the server + const reply = await waitForButtonConfirm( + context, + { content: "User not on server, forceban instead?" }, + { confirmText: "Yes", cancelText: "No", restrictToId: author.id }, + ); + + if (!reply) { + sendErrorMessage(pluginData, context, "User not on server, ban cancelled by moderator"); + lock.unlock(); + return; + } else { + forceban = true; + } + } + + // Abort if trying to ban user indefinitely if they are already banned indefinitely + if (!existingTempban && !time) { + sendErrorMessage(pluginData, context, `User is already banned indefinitely.`); + return; + } + + // Ask the mod if we should update the existing ban + const reply = await waitForButtonConfirm( + context, + { content: "Failed to message the user. Log the warning anyway?" }, + { confirmText: "Yes", cancelText: "No", restrictToId: author.id }, + ); + + if (!reply) { + sendErrorMessage(pluginData, context, "User already banned, update cancelled by moderator"); + lock.unlock(); + return; + } + + // Update or add new tempban / remove old tempban + if (time && time > 0) { + if (existingTempban) { + await pluginData.state.tempbans.updateExpiryTime(user.id, time, mod.id); + } else { + await pluginData.state.tempbans.addTempban(user.id, time, mod.id); + } + const tempban = (await pluginData.state.tempbans.findExistingTempbanForUserId(user.id))!; + registerExpiringTempban(tempban); + } else if (existingTempban) { + clearExpiringTempban(existingTempban); + pluginData.state.tempbans.clear(user.id); + } + + // Create a new case for the updated ban since we never stored the old case id and log the action + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + modId: mod.id, + type: CaseTypes.Ban, + userId: user.id, + reason: formattedReason, + noteDetails: [`Ban updated to ${time ? humanizeDuration(time) : "indefinite"}`], + }); + if (time) { + pluginData.getPlugin(LogsPlugin).logMemberTimedBan({ + mod: mod.user, + user, + caseNumber: createdCase.case_number, + reason: formattedReason, + banTime: humanizeDuration(time), + }); + } else { + pluginData.getPlugin(LogsPlugin).logMemberBan({ + mod: mod.user, + user, + caseNumber: createdCase.case_number, + reason: formattedReason, + }); + } + + sendSuccessMessage( + pluginData, + context, + `Ban updated to ${time ? "expire in " + humanizeDuration(time) + " from now" : "indefinite"}`, + ); + lock.unlock(); + return; + } + + // Make sure we're allowed to ban this member if they are on the server + if (!forceban && !canActOn(pluginData, author, memberToBan!)) { + const ourLevel = getMemberLevel(pluginData, author); + const targetLevel = getMemberLevel(pluginData, memberToBan!); + sendErrorMessage( + pluginData, + context, + `Cannot ban: target permission level is equal or higher to yours, ${targetLevel} >= ${ourLevel}`, + ); + lock.unlock(); + return; + } + + const matchingConfig = await pluginData.config.getMatchingConfig({ + member: author, + channel: isContextInteraction(context) ? context.channel : context, + }); + const deleteMessageDays = deleteDays ?? matchingConfig.ban_delete_message_days; + const banResult = await banUserId( + pluginData, + user.id, + formattedReason, + { + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== author.id ? author.id : undefined, + }, + deleteMessageDays, + modId: mod.id, + }, + time ?? undefined, + ); + + if (banResult.status === "failed") { + sendErrorMessage(pluginData, context, `Failed to ban member: ${banResult.error}`); + lock.unlock(); + return; + } + + let forTime = ""; + if (time && time > 0) { + forTime = `for ${humanizeDuration(time)} `; + } + + // Confirm the action to the moderator + let response = ""; + if (!forceban) { + response = `Banned **${renderUserUsername(user)}** ${forTime}(Case #${banResult.case.case_number})`; + if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`; + } else { + response = `Member forcebanned ${forTime}(Case #${banResult.case.case_number})`; + } + + lock.unlock(); + sendSuccessMessage(pluginData, context, response); +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualCaseCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualCaseCmd.ts new file mode 100644 index 00000000..38ddf2bf --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualCaseCmd.ts @@ -0,0 +1,24 @@ +import { ChatInputCommandInteraction, TextBasedChannel } from "discord.js"; +import { GuildPluginData } from "knub"; +import { sendContextResponse, sendErrorMessage } from "../../../../pluginUtils"; +import { CasesPlugin } from "../../../Cases/CasesPlugin"; +import { ModActionsPluginType } from "../../types"; + +export async function actualCaseCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + authorId: string, + caseNumber: number, +) { + const theCase = await pluginData.state.cases.findByCaseNumber(caseNumber); + + if (!theCase) { + sendErrorMessage(pluginData, context, "Case not found"); + return; + } + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const embed = await casesPlugin.getCaseEmbed(theCase.id, authorId); + + sendContextResponse(context, embed); +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualCasesCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualCasesCmd.ts new file mode 100644 index 00000000..22a16e93 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualCasesCmd.ts @@ -0,0 +1,258 @@ +import { APIEmbed, ChatInputCommandInteraction, TextBasedChannel, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { In } from "typeorm"; +import { FindOptionsWhere } from "typeorm/find-options/FindOptionsWhere"; +import { CaseTypes } from "../../../../data/CaseTypes"; +import { Case } from "../../../../data/entities/Case"; +import { sendContextResponse, sendErrorMessage } from "../../../../pluginUtils"; +import { + UnknownUser, + chunkArray, + emptyEmbedValue, + renderUserUsername, + resolveUser, + trimLines, +} from "../../../../utils"; +import { asyncMap } from "../../../../utils/async"; +import { createPaginatedMessage } from "../../../../utils/createPaginatedMessage"; +import { getChunkedEmbedFields } from "../../../../utils/getChunkedEmbedFields"; +import { getGuildPrefix } from "../../../../utils/getGuildPrefix"; +import { CasesPlugin } from "../../../Cases/CasesPlugin"; +import { ModActionsPluginType } from "../../types"; + +const casesPerPage = 5; +const maxExpandedCases = 8; + +async function sendExpandedCases( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + casesCount: number, + cases: Case[], +) { + if (casesCount > maxExpandedCases) { + await sendContextResponse(context, "Too many cases for expanded view. Please use compact view instead."); + + return; + } + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + + for (const theCase of cases) { + const embed = await casesPlugin.getCaseEmbed(theCase.id); + await sendContextResponse(context, embed); + } +} + +async function casesUserCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + author: User, + modId: string | null, + user: User | UnknownUser, + modName: string, + typesToShow: CaseTypes[], + hidden: boolean | null, + expand: boolean | null, +) { + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const casesFilters: Omit, "guild_id" | "user_id"> = { type: In(typesToShow) }; + + if (modId) { + casesFilters.mod_id = modId; + } + + const cases = await pluginData.state.cases.with("notes").getByUserId(user.id, casesFilters); + 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 : renderUserUsername(user); + + if (cases.length === 0) { + await sendContextResponse(context, `No cases found for **${userName}**${modId ? ` by ${modName}` : ""}.`); + return; + } + + const casesToDisplay = hidden ? cases : normalCases; + + if (!casesToDisplay.length) { + await sendContextResponse( + context, + `No normal cases found for **${userName}**. Use "-hidden" to show ${cases.length} hidden cases.`, + ); + + return; + } + + if (expand) { + sendExpandedCases(pluginData, context, casesToDisplay.length, casesToDisplay); + return; + } + + // Compact view (= regular message with a preview of each case) + const lines = await asyncMap(casesToDisplay, (c) => casesPlugin.getCaseSummary(c, true, author.id)); + + const prefix = getGuildPrefix(pluginData); + const linesPerChunk = 10; + const lineChunks = chunkArray(lines, linesPerChunk); + + const footerField = { + name: emptyEmbedValue, + value: trimLines(` + Use \`${prefix}case \` to see more information about an individual case + `), + }; + + for (const [i, linesInChunk] of lineChunks.entries()) { + const isLastChunk = i === lineChunks.length - 1; + + if (isLastChunk && !hidden && hiddenCases.length) { + if (hiddenCases.length === 1) { + linesInChunk.push(`*+${hiddenCases.length} hidden case, use "-hidden" to show it*`); + } else { + linesInChunk.push(`*+${hiddenCases.length} hidden cases, use "-hidden" to show them*`); + } + } + + const chunkStart = i * linesPerChunk + 1; + const chunkEnd = Math.min((i + 1) * linesPerChunk, lines.length); + + const embed = { + author: { + name: + lineChunks.length === 1 + ? `Cases for ${userName}${modId ? ` by ${modName}` : ""} (${lines.length} total)` + : `Cases ${chunkStart}–${chunkEnd} of ${lines.length} for ${userName}`, + icon_url: user instanceof User ? user.displayAvatarURL() : undefined, + }, + fields: [ + ...getChunkedEmbedFields(emptyEmbedValue, linesInChunk.join("\n")), + ...(isLastChunk ? [footerField] : []), + ], + } satisfies APIEmbed; + + sendContextResponse(context, { embeds: [embed] }); + } +} + +async function casesModCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + author: User, + modId: string | null, + mod: User | UnknownUser, + modName: string, + typesToShow: CaseTypes[], + hidden: boolean | null, + expand: boolean | null, +) { + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const caseFilters = { type: In(typesToShow), is_hidden: !!hidden }; + + const totalCases = await casesPlugin.getTotalCasesByMod(modId ?? author.id, caseFilters); + + if (totalCases === 0) { + sendErrorMessage(pluginData, context, `No cases by **${modName}**`); + return; + } + + const totalPages = Math.max(Math.ceil(totalCases / casesPerPage), 1); + const prefix = getGuildPrefix(pluginData); + + if (expand) { + // Expanded view (= individual case embeds) + const cases = totalCases > 8 ? [] : await casesPlugin.getRecentCasesByMod(modId ?? author.id, 8, 0, caseFilters); + + sendExpandedCases(pluginData, context, totalCases, cases); + return; + } + + createPaginatedMessage( + pluginData.client, + context, + totalPages, + async (page) => { + const cases = await casesPlugin.getRecentCasesByMod( + modId ?? author.id, + casesPerPage, + (page - 1) * casesPerPage, + caseFilters, + ); + + const lines = await asyncMap(cases, (c) => casesPlugin.getCaseSummary(c, true, author.id)); + const firstCaseNum = (page - 1) * casesPerPage + 1; + const lastCaseNum = firstCaseNum - 1 + Math.min(cases.length, casesPerPage); + const title = `Most recent cases ${firstCaseNum}-${lastCaseNum} of ${totalCases} by ${modName}`; + + const embed = { + author: { + name: title, + icon_url: mod instanceof User ? mod.displayAvatarURL() : undefined, + }, + fields: [ + ...getChunkedEmbedFields(emptyEmbedValue, lines.join("\n")), + { + name: emptyEmbedValue, + value: trimLines(` + Use \`${prefix}case \` to see more information about an individual case + Use \`${prefix}cases \` to see a specific user's cases + `), + }, + ], + } satisfies APIEmbed; + + return { embeds: [embed] }; + }, + { + limitToUserId: author.id, + }, + ); +} + +export async function actualCasesCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + modId: string | null, + user: User | UnknownUser | null, + author: User, + notes: boolean | null, + warns: boolean | null, + mutes: boolean | null, + unmutes: boolean | null, + bans: boolean | null, + unbans: boolean | null, + reverseFilters: boolean | null, + hidden: boolean | null, + expand: boolean | null, +) { + const mod = modId ? await resolveUser(pluginData.client, modId) : null; + const modName = modId ? (mod instanceof User ? renderUserUsername(mod) : modId) : renderUserUsername(author); + + let typesToShow: CaseTypes[] = []; + + if (notes) typesToShow.push(CaseTypes.Note); + if (warns) typesToShow.push(CaseTypes.Warn); + if (mutes) typesToShow.push(CaseTypes.Mute); + if (unmutes) typesToShow.push(CaseTypes.Unmute); + if (bans) typesToShow.push(CaseTypes.Ban); + if (unbans) typesToShow.push(CaseTypes.Unban); + + if (typesToShow.length === 0) { + typesToShow = [CaseTypes.Note, CaseTypes.Warn, CaseTypes.Mute, CaseTypes.Unmute, CaseTypes.Ban, CaseTypes.Unban]; + } else { + if (reverseFilters) { + typesToShow = [ + CaseTypes.Note, + CaseTypes.Warn, + CaseTypes.Mute, + CaseTypes.Unmute, + CaseTypes.Ban, + CaseTypes.Unban, + ].filter((t) => !typesToShow.includes(t)); + } + } + + user + ? casesUserCmd(pluginData, context, author, modId!, user, modName, typesToShow, hidden, expand) + : casesModCmd(pluginData, context, author, modId!, mod!, modName, typesToShow, hidden, expand); +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualDeleteCaseCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualDeleteCaseCmd.ts new file mode 100644 index 00000000..3fac80cf --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualDeleteCaseCmd.ts @@ -0,0 +1,95 @@ +import { ChatInputCommandInteraction, GuildMember, TextBasedChannel } from "discord.js"; +import { GuildPluginData, helpers } from "knub"; +import { Case } from "../../../../data/entities/Case"; +import { + isContextInteraction, + sendContextResponse, + sendErrorMessage, + sendSuccessMessage, +} from "../../../../pluginUtils"; +import { SECONDS } from "../../../../utils"; +import { CasesPlugin } from "../../../Cases/CasesPlugin"; +import { LogsPlugin } from "../../../Logs/LogsPlugin"; +import { TimeAndDatePlugin } from "../../../TimeAndDate/TimeAndDatePlugin"; +import { ModActionsPluginType } from "../../types"; + +export async function actualDeleteCaseCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + author: GuildMember, + caseNumbers: number[], + force: boolean, +) { + const failed: number[] = []; + const validCases: Case[] = []; + let cancelled = 0; + + for (const num of caseNumbers) { + const theCase = await pluginData.state.cases.findByCaseNumber(num); + if (!theCase) { + failed.push(num); + continue; + } + + validCases.push(theCase); + } + + if (failed.length === caseNumbers.length) { + sendErrorMessage(pluginData, context, "None of the cases were found!"); + return; + } + + for (const theCase of validCases) { + if (!force) { + const cases = pluginData.getPlugin(CasesPlugin); + const embedContent = await cases.getCaseEmbed(theCase); + sendContextResponse(context, { + ...embedContent, + content: "Delete the following case? Answer 'Yes' to continue, 'No' to cancel.", + }); + + const reply = await helpers.waitForReply( + pluginData.client, + isContextInteraction(context) ? context.channel! : context, + author.id, + 15 * SECONDS, + ); + const normalizedReply = (reply?.content || "").toLowerCase().trim(); + if (normalizedReply !== "yes" && normalizedReply !== "y") { + sendContextResponse(context, "Cancelled. Case was not deleted."); + cancelled++; + continue; + } + } + + const deletedByName = author.user.tag; + + const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); + const deletedAt = timeAndDate.inGuildTz().format(timeAndDate.getDateFormat("pretty_datetime")); + + await pluginData.state.cases.softDelete( + theCase.id, + author.id, + deletedByName, + `Case deleted by **${deletedByName}** (\`${author.id}\`) on ${deletedAt}`, + ); + + const logs = pluginData.getPlugin(LogsPlugin); + logs.logCaseDelete({ + mod: author, + case: theCase, + }); + } + + const failedAddendum = + failed.length > 0 + ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` + : ""; + const amt = validCases.length - cancelled; + if (amt === 0) { + sendErrorMessage(pluginData, context, "All deletions were cancelled, no cases were deleted."); + return; + } + + sendSuccessMessage(pluginData, context, `${amt} case${amt === 1 ? " was" : "s were"} deleted!${failedAddendum}`); +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualForceBanCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualForceBanCmd.ts new file mode 100644 index 00000000..095e06a7 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualForceBanCmd.ts @@ -0,0 +1,60 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Snowflake, TextBasedChannel, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes"; +import { LogType } from "../../../../data/LogType"; +import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils"; +import { DAYS, MINUTES, UnknownUser } from "../../../../utils"; +import { CasesPlugin } from "../../../Cases/CasesPlugin"; +import { LogsPlugin } from "../../../Logs/LogsPlugin"; +import { IgnoredEventType, ModActionsPluginType } from "../../types"; +import { formatReasonWithAttachments } from "../formatReasonWithAttachments"; +import { ignoreEvent } from "../ignoreEvent"; + +export async function actualForceBanCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + authorId: string, + user: User | UnknownUser, + reason: string, + attachments: Array, + mod: GuildMember, +) { + const formattedReason = formatReasonWithAttachments(reason, attachments); + + ignoreEvent(pluginData, IgnoredEventType.Ban, user.id); + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id); + + try { + // FIXME: Use banUserId()? + await pluginData.guild.bans.create(user.id as Snowflake, { + deleteMessageSeconds: (1 * DAYS) / MINUTES, + reason: formattedReason ?? undefined, + }); + } catch { + sendErrorMessage(pluginData, context, "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: formattedReason, + ppId: mod.id !== authorId ? authorId : undefined, + }); + + // Confirm the action + sendSuccessMessage(pluginData, context, `Member forcebanned (Case #${createdCase.case_number})`); + + // Log the action + pluginData.getPlugin(LogsPlugin).logMemberForceban({ + mod, + userId: user.id, + caseNumber: createdCase.case_number, + reason: formattedReason, + }); + + pluginData.state.events.emit("ban", user.id, formattedReason); +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualHideCaseCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualHideCaseCmd.ts new file mode 100644 index 00000000..d500ce5f --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualHideCaseCmd.ts @@ -0,0 +1,38 @@ +import { ChatInputCommandInteraction, TextBasedChannel } from "discord.js"; +import { GuildPluginData } from "knub"; +import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils"; +import { ModActionsPluginType } from "../../types"; + +export async function actualHideCaseCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + caseNumbers: number[], +) { + const failed: number[] = []; + + for (const num of caseNumbers) { + const theCase = await pluginData.state.cases.findByCaseNumber(num); + if (!theCase) { + failed.push(num); + continue; + } + + await pluginData.state.cases.setHidden(theCase.id, true); + } + + if (failed.length === caseNumbers.length) { + sendErrorMessage(pluginData, context, "None of the cases were found!"); + return; + } + const failedAddendum = + failed.length > 0 + ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` + : ""; + + const amt = caseNumbers.length - failed.length; + sendSuccessMessage( + pluginData, + context, + `${amt} case${amt === 1 ? " is" : "s are"} now hidden! Use \`unhidecase\` to unhide them.${failedAddendum}`, + ); +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualKickCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualKickCmd.ts new file mode 100644 index 00000000..e66383fe --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualKickCmd.ts @@ -0,0 +1,89 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, TextBasedChannel, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { LogType } from "../../../../data/LogType"; +import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils"; +import { + DAYS, + SECONDS, + UnknownUser, + UserNotificationMethod, + renderUserUsername, + resolveMember, +} from "../../../../utils"; +import { IgnoredEventType, ModActionsPluginType } from "../../types"; +import { formatReasonWithAttachments } from "../formatReasonWithAttachments"; +import { ignoreEvent } from "../ignoreEvent"; +import { isBanned } from "../isBanned"; +import { kickMember } from "../kickMember"; + +export async function actualKickCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + author: GuildMember, + user: User | UnknownUser, + reason: string, + attachments: Attachment[], + mod: GuildMember, + contactMethods?: UserNotificationMethod[], + clean?: boolean, +) { + const memberToKick = await resolveMember(pluginData.client, pluginData.guild, user.id); + + if (!memberToKick) { + const banned = await isBanned(pluginData, user.id); + if (banned) { + sendErrorMessage(pluginData, context, `User is banned`); + } else { + sendErrorMessage(pluginData, context, `User not found on the server`); + } + + return; + } + + // Make sure we're allowed to kick this member + if (!canActOn(pluginData, author, memberToKick)) { + sendErrorMessage(pluginData, context, "Cannot kick: insufficient permissions"); + return; + } + + const formattedReason = formatReasonWithAttachments(reason, attachments); + + const kickResult = await kickMember(pluginData, memberToKick, formattedReason, { + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== author.id ? author.id : undefined, + }, + }); + + if (clean) { + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, memberToKick.id); + ignoreEvent(pluginData, IgnoredEventType.Ban, memberToKick.id); + + try { + await memberToKick.ban({ deleteMessageSeconds: (1 * DAYS) / SECONDS, reason: "kick -clean" }); + } catch { + sendErrorMessage(pluginData, context, "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.bans.remove(memberToKick.id, "kick -clean"); + } catch { + sendErrorMessage(pluginData, context, "Failed to unban the user after banning them (-clean)"); + } + } + + if (kickResult.status === "failed") { + sendErrorMessage(pluginData, context, `Failed to kick user`); + return; + } + + // Confirm the action to the moderator + let response = `Kicked **${renderUserUsername(memberToKick.user)}** (Case #${kickResult.case.case_number})`; + + if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`; + sendSuccessMessage(pluginData, context, response); +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualMassBanCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualMassBanCmd.ts new file mode 100644 index 00000000..c0abc3a8 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualMassBanCmd.ts @@ -0,0 +1,163 @@ +import { ChatInputCommandInteraction, GuildMember, Snowflake, TextBasedChannel } from "discord.js"; +import { GuildPluginData } from "knub"; +import { waitForReply } from "knub/helpers"; +import { CaseTypes } from "../../../../data/CaseTypes"; +import { LogType } from "../../../../data/LogType"; +import { humanizeDurationShort } from "../../../../humanizeDurationShort"; +import { + canActOn, + isContextInteraction, + sendContextResponse, + sendErrorMessage, + sendSuccessMessage, +} from "../../../../pluginUtils"; +import { DAYS, MINUTES, SECONDS, noop } from "../../../../utils"; +import { CasesPlugin } from "../../../Cases/CasesPlugin"; +import { LogsPlugin } from "../../../Logs/LogsPlugin"; +import { IgnoredEventType, ModActionsPluginType } from "../../types"; +import { formatReasonWithAttachments } from "../formatReasonWithAttachments"; +import { ignoreEvent } from "../ignoreEvent"; + +export async function actualMassBanCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + userIds: string[], + author: GuildMember, +) { + // Limit to 100 users at once (arbitrary?) + if (userIds.length > 100) { + sendErrorMessage(pluginData, context, `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) + sendContextResponse(context, "Ban reason? `cancel` to cancel"); + const banReasonReply = await waitForReply( + pluginData.client, + isContextInteraction(context) ? context.channel! : context, + author.id, + ); + + if (!banReasonReply || !banReasonReply.content || banReasonReply.content.toLowerCase().trim() === "cancel") { + sendErrorMessage(pluginData, context, "Cancelled"); + return; + } + + const banReason = formatReasonWithAttachments(banReasonReply.content, [...banReasonReply.attachments.values()]); + + // Verify we can act on each of the users specified + for (const userId of userIds) { + const member = pluginData.guild.members.cache.get(userId as Snowflake); // TODO: Get members on demand? + if (member && !canActOn(pluginData, author, member)) { + sendErrorMessage(pluginData, context, "Cannot massban one or more users: insufficient permissions"); + return; + } + } + + // Show a loading indicator since this can take a while + const maxWaitTime = pluginData.state.massbanQueue.timeout * pluginData.state.massbanQueue.length; + const maxWaitTimeFormatted = humanizeDurationShort(maxWaitTime, { round: true }); + const initialLoadingText = + pluginData.state.massbanQueue.length === 0 + ? "Banning..." + : `Massban queued. Waiting for previous massban to finish (max wait ${maxWaitTimeFormatted}).`; + const loadingMsg = await sendContextResponse(context, initialLoadingText); + + const waitTimeStart = performance.now(); + const waitingInterval = setInterval(() => { + const waitTime = humanizeDurationShort(performance.now() - waitTimeStart, { round: true }); + loadingMsg + .edit(`Massban queued. Still waiting for previous massban to finish (waited ${waitTime}).`) + .catch(() => clearInterval(waitingInterval)); + }, 1 * MINUTES); + + pluginData.state.massbanQueue.add(async () => { + clearInterval(waitingInterval); + + if (pluginData.state.unloaded) { + void loadingMsg.delete().catch(noop); + return; + } + + void loadingMsg.edit("Banning...").catch(noop); + + // Ban each user and count failed bans (if any) + const startTime = performance.now(); + const failedBans: string[] = []; + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const messageConfig = isContextInteraction(context) + ? await pluginData.config.getForInteraction(context) + : await pluginData.config.getForChannel(context); + const deleteDays = messageConfig.ban_delete_message_days; + + for (const [i, userId] of userIds.entries()) { + if (pluginData.state.unloaded) { + break; + } + + try { + // Ignore automatic ban cases and logs + // We create our own cases below and post a single "mass banned" log instead + ignoreEvent(pluginData, IgnoredEventType.Ban, userId, 30 * MINUTES); + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId, 30 * MINUTES); + + await pluginData.guild.bans.create(userId as Snowflake, { + deleteMessageSeconds: (deleteDays * DAYS) / SECONDS, + reason: banReason, + }); + + await casesPlugin.createCase({ + userId, + modId: author.id, + type: CaseTypes.Ban, + reason: `Mass ban: ${banReason}`, + postInCaseLogOverride: false, + }); + + pluginData.state.events.emit("ban", userId, banReason); + } catch { + failedBans.push(userId); + } + + // Send a status update every 10 bans + if ((i + 1) % 10 === 0) { + loadingMsg.edit(`Banning... ${i + 1}/${userIds.length}`).catch(noop); + } + } + + const totalTime = performance.now() - startTime; + const formattedTimeTaken = humanizeDurationShort(totalTime, { round: true }); + + // Clear loading indicator + loadingMsg.delete().catch(noop); + + const successfulBanCount = userIds.length - failedBans.length; + if (successfulBanCount === 0) { + // All bans failed - don't create a log entry and notify the user + sendErrorMessage(pluginData, context, "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.getPlugin(LogsPlugin).logMassBan({ + mod: author.user, + count: successfulBanCount, + reason: banReason, + }); + + if (failedBans.length) { + sendSuccessMessage( + pluginData, + context, + `Banned ${successfulBanCount} users in ${formattedTimeTaken}, ${failedBans.length} failed: ${failedBans.join( + " ", + )}`, + ); + } else { + sendSuccessMessage( + pluginData, + context, + `Banned ${successfulBanCount} users successfully in ${formattedTimeTaken}`, + ); + } + } + }); +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualMassMuteCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualMassMuteCmd.ts new file mode 100644 index 00000000..8dd735ab --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualMassMuteCmd.ts @@ -0,0 +1,110 @@ +import { ChatInputCommandInteraction, GuildMember, Snowflake, TextBasedChannel } from "discord.js"; +import { GuildPluginData } from "knub"; +import { waitForReply } from "knub/helpers"; +import { LogType } from "../../../../data/LogType"; +import { logger } from "../../../../logger"; +import { + canActOn, + isContextInteraction, + sendContextResponse, + sendErrorMessage, + sendSuccessMessage, +} from "../../../../pluginUtils"; +import { LogsPlugin } from "../../../Logs/LogsPlugin"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin"; +import { ModActionsPluginType } from "../../types"; +import { formatReasonWithAttachments } from "../formatReasonWithAttachments"; + +export async function actualMassMuteCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + userIds: string[], + author: GuildMember, +) { + // Limit to 100 users at once (arbitrary?) + if (userIds.length > 100) { + sendErrorMessage(pluginData, context, `Can only massmute max 100 users at once`); + return; + } + + // Ask for mute reason + sendContextResponse(context, "Mute reason? `cancel` to cancel"); + const muteReasonReceived = await waitForReply( + pluginData.client, + isContextInteraction(context) ? context.channel! : context, + author.id, + ); + if ( + !muteReasonReceived || + !muteReasonReceived.content || + muteReasonReceived.content.toLowerCase().trim() === "cancel" + ) { + sendErrorMessage(pluginData, context, "Cancelled"); + return; + } + + const muteReason = formatReasonWithAttachments(muteReasonReceived.content, [ + ...muteReasonReceived.attachments.values(), + ]); + + // Verify we can act upon all users + for (const userId of userIds) { + const member = pluginData.guild.members.cache.get(userId as Snowflake); + if (member && !canActOn(pluginData, author, member)) { + sendErrorMessage(pluginData, context, "Cannot massmute one or more users: insufficient permissions"); + return; + } + } + + // Ignore automatic mute cases and logs for these users + // We'll create our own cases below and post a single "mass muted" log instead + userIds.forEach((userId) => { + // Use longer timeouts since this can take a while + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_MUTE, userId, 120 * 1000); + }); + + // Show loading indicator + const loadingMsg = await sendContextResponse(context, "Muting..."); + + // Mute everyone and count fails + const modId = author.id; + const failedMutes: string[] = []; + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + for (const userId of userIds) { + try { + await mutesPlugin.muteUser(userId, 0, `Mass mute: ${muteReason}`, { + caseArgs: { + modId, + }, + }); + } catch (e) { + logger.info(e); + failedMutes.push(userId); + } + } + + // Clear loading indicator + loadingMsg.delete(); + + const successfulMuteCount = userIds.length - failedMutes.length; + if (successfulMuteCount === 0) { + // All mutes failed + sendErrorMessage(pluginData, context, "All mutes failed. Make sure the IDs are valid."); + } else { + // Success on all or some mutes + pluginData.getPlugin(LogsPlugin).logMassMute({ + mod: author.user, + count: successfulMuteCount, + }); + + if (failedMutes.length) { + sendSuccessMessage( + pluginData, + context, + `Muted ${successfulMuteCount} users, ${failedMutes.length} failed: ${failedMutes.join(" ")}`, + ); + } else { + sendSuccessMessage(pluginData, context, `Muted ${successfulMuteCount} users successfully`); + } + } +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualMassUnbanCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualMassUnbanCmd.ts new file mode 100644 index 00000000..cf829fd7 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualMassUnbanCmd.ts @@ -0,0 +1,127 @@ +import { ChatInputCommandInteraction, GuildMember, Snowflake, TextBasedChannel } from "discord.js"; +import { GuildPluginData } from "knub"; +import { waitForReply } from "knub/helpers"; +import { CaseTypes } from "../../../../data/CaseTypes"; +import { LogType } from "../../../../data/LogType"; +import { + isContextInteraction, + sendContextResponse, + sendErrorMessage, + sendSuccessMessage, +} from "../../../../pluginUtils"; +import { CasesPlugin } from "../../../Cases/CasesPlugin"; +import { LogsPlugin } from "../../../Logs/LogsPlugin"; +import { IgnoredEventType, ModActionsPluginType } from "../../types"; +import { formatReasonWithAttachments } from "../formatReasonWithAttachments"; +import { ignoreEvent } from "../ignoreEvent"; +import { isBanned } from "../isBanned"; + +export async function actualMassUnbanCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + userIds: string[], + author: GuildMember, +) { + // Limit to 100 users at once (arbitrary?) + if (userIds.length > 100) { + sendErrorMessage(pluginData, context, `Can only mass-unban max 100 users at once`); + return; + } + + // Ask for unban reason (cleaner this way instead of trying to cram it into the args) + sendContextResponse(context, "Unban reason? `cancel` to cancel"); + const unbanReasonReply = await waitForReply( + pluginData.client, + isContextInteraction(context) ? context.channel! : context, + author.id, + ); + if (!unbanReasonReply || !unbanReasonReply.content || unbanReasonReply.content.toLowerCase().trim() === "cancel") { + sendErrorMessage(pluginData, context, "Cancelled"); + return; + } + + const unbanReason = formatReasonWithAttachments(unbanReasonReply.content, [...unbanReasonReply.attachments.values()]); + + // Ignore automatic unban cases and logs for these users + // We'll create our own cases below and post a single "mass unbanned" log instead + userIds.forEach((userId) => { + // Use longer timeouts since this can take a while + ignoreEvent(pluginData, IgnoredEventType.Unban, userId, 120 * 1000); + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, userId, 120 * 1000); + }); + + // Show a loading indicator since this can take a while + const loadingMsg = await sendContextResponse(context, "Unbanning..."); + + // Unban each user and count failed unbans (if any) + const failedUnbans: Array<{ userId: string; reason: UnbanFailReasons }> = []; + const casesPlugin = pluginData.getPlugin(CasesPlugin); + for (const userId of userIds) { + if (!(await isBanned(pluginData, userId))) { + failedUnbans.push({ userId, reason: UnbanFailReasons.NOT_BANNED }); + continue; + } + + try { + await pluginData.guild.bans.remove(userId as Snowflake, unbanReason ?? undefined); + + await casesPlugin.createCase({ + userId, + modId: author.id, + type: CaseTypes.Unban, + reason: `Mass unban: ${unbanReason}`, + postInCaseLogOverride: false, + }); + } catch { + failedUnbans.push({ userId, reason: UnbanFailReasons.UNBAN_FAILED }); + } + } + + // Clear loading indicator + loadingMsg.delete(); + + const successfulUnbanCount = userIds.length - failedUnbans.length; + if (successfulUnbanCount === 0) { + // All unbans failed - don't create a log entry and notify the user + sendErrorMessage(pluginData, context, "All unbans failed. Make sure the IDs are valid and banned."); + } else { + // Some or all unbans were successful. Create a log entry for the mass unban and notify the user. + pluginData.getPlugin(LogsPlugin).logMassUnban({ + mod: author.user, + count: successfulUnbanCount, + reason: unbanReason, + }); + + if (failedUnbans.length) { + const notBanned = failedUnbans.filter((x) => x.reason === UnbanFailReasons.NOT_BANNED); + const unbanFailed = failedUnbans.filter((x) => x.reason === UnbanFailReasons.UNBAN_FAILED); + + let failedMsg = ""; + if (notBanned.length > 0) { + failedMsg += `${notBanned.length}x ${UnbanFailReasons.NOT_BANNED}:`; + notBanned.forEach((fail) => { + failedMsg += " " + fail.userId; + }); + } + if (unbanFailed.length > 0) { + failedMsg += `\n${unbanFailed.length}x ${UnbanFailReasons.UNBAN_FAILED}:`; + unbanFailed.forEach((fail) => { + failedMsg += " " + fail.userId; + }); + } + + sendSuccessMessage( + pluginData, + context, + `Unbanned ${successfulUnbanCount} users, ${failedUnbans.length} failed:\n${failedMsg}`, + ); + } else { + sendSuccessMessage(pluginData, context, `Unbanned ${successfulUnbanCount} users successfully`); + } + } +} + +enum UnbanFailReasons { + NOT_BANNED = "Not banned", + UNBAN_FAILED = "Unban failed", +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualMuteCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualMuteCmd.ts new file mode 100644 index 00000000..487cf453 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualMuteCmd.ts @@ -0,0 +1,96 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, TextBasedChannel, User } from "discord.js"; +import humanizeDuration from "humanize-duration"; +import { GuildPluginData } from "knub"; +import { ERRORS, RecoverablePluginError } from "../../../../RecoverablePluginError"; +import { logger } from "../../../../logger"; +import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils"; +import { + UnknownUser, + UserNotificationMethod, + asSingleLine, + isDiscordAPIError, + renderUserUsername, +} from "../../../../utils"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin"; +import { MuteResult } from "../../../Mutes/types"; +import { ModActionsPluginType } from "../../types"; +import { formatReasonWithAttachments } from "../formatReasonWithAttachments"; + +/** + * The actual function run by both !mute and !forcemute. + * The only difference between the two commands is in target member validation. + */ +export async function actualMuteCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + user: User | UnknownUser, + attachments: Array, + mod: GuildMember, + ppId?: string, + time?: number, + reason?: string, + contactMethods?: UserNotificationMethod[], +) { + const timeUntilUnmute = time && humanizeDuration(time); + const formattedReason = reason ? formatReasonWithAttachments(reason, attachments) : undefined; + + let muteResult: MuteResult; + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + + try { + muteResult = await mutesPlugin.muteUser(user.id, time, formattedReason, { + contactMethods, + caseArgs: { + modId: mod.id, + ppId, + }, + }); + } catch (e) { + if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { + sendErrorMessage(pluginData, context, "Could not mute the user: no mute role set in config"); + } else if (isDiscordAPIError(e) && e.code === 10007) { + sendErrorMessage(pluginData, context, "Could not mute the user: unknown member"); + } else { + logger.error(`Failed to mute user ${user.id}: ${e.stack}`); + if (user.id == null) { + // FIXME: Debug + // tslint:disable-next-line:no-console + console.trace("[DEBUG] Null user.id for mute"); + } + sendErrorMessage(pluginData, context, "Could not mute the user"); + } + + return; + } + + // Confirm the action to the moderator + let response: string; + if (time) { + if (muteResult.updatedExistingMute) { + response = asSingleLine(` + Updated **${renderUserUsername(user)}**'s + mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number}) + `); + } else { + response = asSingleLine(` + Muted **${renderUserUsername(user)}** + for ${timeUntilUnmute} (Case #${muteResult.case.case_number}) + `); + } + } else { + if (muteResult.updatedExistingMute) { + response = asSingleLine(` + Updated **${renderUserUsername(user)}**'s + mute to indefinite (Case #${muteResult.case.case_number}) + `); + } else { + response = asSingleLine(` + Muted **${renderUserUsername(user)}** + indefinitely (Case #${muteResult.case.case_number}) + `); + } + } + + if (muteResult.notifyResult.text) response += ` (${muteResult.notifyResult.text})`; + sendSuccessMessage(pluginData, context, response); +} diff --git a/backend/src/plugins/ModActions/functions/actualNoteCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualNoteCmd.ts similarity index 71% rename from backend/src/plugins/ModActions/functions/actualNoteCmd.ts rename to backend/src/plugins/ModActions/functions/actualCommands/actualNoteCmd.ts index 524a182c..2a29e2df 100644 --- a/backend/src/plugins/ModActions/functions/actualNoteCmd.ts +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualNoteCmd.ts @@ -1,12 +1,12 @@ import { Attachment, ChatInputCommandInteraction, TextBasedChannel, User } from "discord.js"; import { GuildPluginData } from "knub"; -import { CaseTypes } from "../../../data/CaseTypes"; -import { sendSuccessMessage } from "../../../pluginUtils"; -import { UnknownUser, renderUserUsername } from "../../../utils"; -import { CasesPlugin } from "../../Cases/CasesPlugin"; -import { LogsPlugin } from "../../Logs/LogsPlugin"; -import { ModActionsPluginType } from "../types"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; +import { CaseTypes } from "../../../../data/CaseTypes"; +import { sendSuccessMessage } from "../../../../pluginUtils"; +import { UnknownUser, renderUserUsername } from "../../../../utils"; +import { CasesPlugin } from "../../../Cases/CasesPlugin"; +import { LogsPlugin } from "../../../Logs/LogsPlugin"; +import { ModActionsPluginType } from "../../types"; +import { formatReasonWithAttachments } from "../formatReasonWithAttachments"; export async function actualNoteCmd( pluginData: GuildPluginData, diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualUnbanCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualUnbanCmd.ts new file mode 100644 index 00000000..94df7d4f --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualUnbanCmd.ts @@ -0,0 +1,63 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Snowflake, TextBasedChannel, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes"; +import { LogType } from "../../../../data/LogType"; +import { clearExpiringTempban } from "../../../../data/loops/expiringTempbansLoop"; +import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils"; +import { UnknownUser } from "../../../../utils"; +import { CasesPlugin } from "../../../Cases/CasesPlugin"; +import { LogsPlugin } from "../../../Logs/LogsPlugin"; +import { IgnoredEventType, ModActionsPluginType } from "../../types"; +import { formatReasonWithAttachments } from "../formatReasonWithAttachments"; +import { ignoreEvent } from "../ignoreEvent"; + +export async function actualUnbanCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + authorId: string, + user: User | UnknownUser, + reason: string, + attachments: Array, + mod: GuildMember, +) { + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, user.id); + const formattedReason = formatReasonWithAttachments(reason, attachments); + + try { + ignoreEvent(pluginData, IgnoredEventType.Unban, user.id); + await pluginData.guild.bans.remove(user.id as Snowflake, formattedReason ?? undefined); + } catch { + sendErrorMessage(pluginData, context, "Failed to unban member; are you sure they're banned?"); + return; + } + + // Create a case + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: user.id, + modId: mod.id, + type: CaseTypes.Unban, + reason: formattedReason, + ppId: mod.id !== authorId ? authorId : undefined, + }); + + // Delete the tempban, if one exists + const tempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); + if (tempban) { + clearExpiringTempban(tempban); + await pluginData.state.tempbans.clear(user.id); + } + + // Confirm the action + sendSuccessMessage(pluginData, context, `Member unbanned (Case #${createdCase.case_number})`); + + // Log the action + pluginData.getPlugin(LogsPlugin).logMemberUnban({ + mod: mod.user, + userId: user.id, + caseNumber: createdCase.case_number, + reason: formattedReason ?? "", + }); + + pluginData.state.events.emit("unban", user.id); +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualUnhideCaseCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualUnhideCaseCmd.ts new file mode 100644 index 00000000..3ec2cd9e --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualUnhideCaseCmd.ts @@ -0,0 +1,39 @@ +import { ChatInputCommandInteraction, TextBasedChannel } from "discord.js"; +import { GuildPluginData } from "knub"; +import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils"; +import { ModActionsPluginType } from "../../types"; + +export async function actualUnhideCaseCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + caseNumbers: number[], +) { + const failed: number[] = []; + + for (const num of caseNumbers) { + const theCase = await pluginData.state.cases.findByCaseNumber(num); + if (!theCase) { + failed.push(num); + continue; + } + + await pluginData.state.cases.setHidden(theCase.id, false); + } + + if (failed.length === caseNumbers.length) { + sendErrorMessage(pluginData, context, "None of the cases were found!"); + return; + } + + const failedAddendum = + failed.length > 0 + ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` + : ""; + + const amt = caseNumbers.length - failed.length; + sendSuccessMessage( + pluginData, + context, + `${amt} case${amt === 1 ? " is" : "s are"} no longer hidden!${failedAddendum}`, + ); +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualUnmuteCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualUnmuteCmd.ts new file mode 100644 index 00000000..96804c38 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualUnmuteCmd.ts @@ -0,0 +1,55 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, TextBasedChannel, User } from "discord.js"; +import humanizeDuration from "humanize-duration"; +import { GuildPluginData } from "knub"; +import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils"; +import { UnknownUser, asSingleLine, renderUserUsername } from "../../../../utils"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin"; +import { ModActionsPluginType } from "../../types"; +import { formatReasonWithAttachments } from "../formatReasonWithAttachments"; + +export async function actualUnmuteCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + user: User | UnknownUser, + attachments: Array, + mod: GuildMember, + ppId?: string, + time?: number, + reason?: string, +) { + const parsedReason = reason ? formatReasonWithAttachments(reason, attachments) : undefined; + + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + const result = await mutesPlugin.unmuteUser(user.id, time, { + modId: mod.id, + ppId: ppId ?? undefined, + reason: parsedReason, + }); + + if (!result) { + sendErrorMessage(pluginData, context, "User is not muted!"); + return; + } + + // Confirm the action to the moderator + if (time) { + const timeUntilUnmute = time && humanizeDuration(time); + sendSuccessMessage( + pluginData, + context, + asSingleLine(` + Unmuting **${renderUserUsername(user)}** + in ${timeUntilUnmute} (Case #${result.case.case_number}) + `), + ); + } else { + sendSuccessMessage( + pluginData, + context, + asSingleLine(` + Unmuted **${renderUserUsername(user)}** + (Case #${result.case.case_number}) + `), + ); + } +} diff --git a/backend/src/plugins/ModActions/functions/actualCommands/actualWarnCmd.ts b/backend/src/plugins/ModActions/functions/actualCommands/actualWarnCmd.ts new file mode 100644 index 00000000..5748f72e --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualCommands/actualWarnCmd.ts @@ -0,0 +1,61 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, TextBasedChannel } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes"; +import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils"; +import { UserNotificationMethod, renderUserUsername } from "../../../../utils"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction"; +import { CasesPlugin } from "../../../Cases/CasesPlugin"; +import { ModActionsPluginType } from "../../types"; +import { formatReasonWithAttachments } from "../formatReasonWithAttachments"; +import { warnMember } from "../warnMember"; + +export async function actualWarnCmd( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + authorId: string, + mod: GuildMember, + memberToWarn: GuildMember, + reason: string, + attachments: Attachment[], + contactMethods?: UserNotificationMethod[], +) { + const config = pluginData.config.get(); + const formattedReason = formatReasonWithAttachments(reason, attachments); + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const priorWarnAmount = await casesPlugin.getCaseTypeAmountForUserId(memberToWarn.id, CaseTypes.Warn); + if (config.warn_notify_enabled && priorWarnAmount >= config.warn_notify_threshold) { + const reply = await waitForButtonConfirm( + context, + { content: config.warn_notify_message.replace("{priorWarnings}", `${priorWarnAmount}`) }, + { confirmText: "Yes", cancelText: "No", restrictToId: authorId }, + ); + if (!reply) { + sendErrorMessage(pluginData, context, "Warn cancelled by moderator"); + return; + } + } + + const warnResult = await warnMember(pluginData, memberToWarn, formattedReason, { + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== authorId ? authorId : undefined, + reason: formattedReason, + }, + retryPromptContext: context, + }); + + if (warnResult.status === "failed") { + sendErrorMessage(pluginData, context, "Failed to warn user"); + return; + } + + const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : ""; + + sendSuccessMessage( + pluginData, + context, + `Warned **${renderUserUsername(memberToWarn.user)}** (Case #${warnResult.case.case_number})${messageResultText}`, + ); +} diff --git a/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts b/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts deleted file mode 100644 index 73a1e2d9..00000000 --- a/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { GuildMember, GuildTextBasedChannel } from "discord.js"; -import { GuildPluginData } from "knub"; -import { hasPermission } from "knub/helpers"; -import { LogType } from "../../../data/LogType"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { DAYS, SECONDS, errorMessage, renderUserUsername, resolveMember, resolveUser } from "../../../utils"; -import { IgnoredEventType, ModActionsPluginType } from "../types"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; -import { ignoreEvent } from "./ignoreEvent"; -import { isBanned } from "./isBanned"; -import { kickMember } from "./kickMember"; -import { readContactMethodsFromArgs } from "./readContactMethodsFromArgs"; - -export async function actualKickMemberCmd( - pluginData: GuildPluginData, - msg, - args: { - user: string; - reason: string; - mod: GuildMember; - notify?: string; - "notify-channel"?: GuildTextBasedChannel; - clean?: boolean; - }, -) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - 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 (!(await hasPermission(await pluginData.config.getForMessage(msg), "can_act_as_other"))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -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({ deleteMessageSeconds: (1 * DAYS) / SECONDS, reason: "kick -clean" }); - } catch { - 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.bans.remove(memberToKick.id, "kick -clean"); - } catch { - sendErrorMessage(pluginData, msg.channel, "Failed to unban the user after banning them (-clean)"); - } - } - - if (kickResult.status === "failed") { - msg.channel.send(errorMessage(`Failed to kick user`)); - return; - } - - // Confirm the action to the moderator - let response = `Kicked **${renderUserUsername(memberToKick.user)}** (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/actualMuteUserCmd.ts b/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts deleted file mode 100644 index 5c628c4e..00000000 --- a/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { GuildMember, GuildTextBasedChannel, Message, User } from "discord.js"; -import humanizeDuration from "humanize-duration"; -import { GuildPluginData } from "knub"; -import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; -import { logger } from "../../../logger"; -import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { UnknownUser, asSingleLine, isDiscordAPIError, renderUserUsername } from "../../../utils"; -import { MutesPlugin } from "../../Mutes/MutesPlugin"; -import { MuteResult } from "../../Mutes/types"; -import { ModActionsPluginType } from "../types"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; -import { readContactMethodsFromArgs } from "./readContactMethodsFromArgs"; - -/** - * The actual function run by both !mute and !forcemute. - * The only difference between the two commands is in target member validation. - */ -export async function actualMuteUserCmd( - pluginData: GuildPluginData, - user: User | UnknownUser, - msg: Message, - args: { - time?: number; - reason?: string; - mod: GuildMember; - notify?: string; - "notify-channel"?: GuildTextBasedChannel; - }, -) { - // The moderator who did the action is the message author or, if used, the specified -mod - let mod: GuildMember = msg.member!; - let pp: User | null = null; - - if (args.mod) { - if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); - return; - } - - mod = args.mod; - pp = msg.author; - } - - const timeUntilUnmute = args.time && humanizeDuration(args.time); - const reason = args.reason ? formatReasonWithAttachments(args.reason, [...msg.attachments.values()]) : undefined; - - let muteResult: MuteResult; - const mutesPlugin = pluginData.getPlugin(MutesPlugin); - - let contactMethods; - try { - contactMethods = readContactMethodsFromArgs(args); - } catch (e) { - sendErrorMessage(pluginData, msg.channel, e.message); - return; - } - - try { - muteResult = await mutesPlugin.muteUser(user.id, args.time, reason, { - contactMethods, - caseArgs: { - modId: mod.id, - ppId: pp ? pp.id : undefined, - }, - }); - } catch (e) { - if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { - sendErrorMessage(pluginData, msg.channel, "Could not mute the user: no mute role set in config"); - } else if (isDiscordAPIError(e) && e.code === 10007) { - sendErrorMessage(pluginData, msg.channel, "Could not mute the user: unknown member"); - } else { - logger.error(`Failed to mute user ${user.id}: ${e.stack}`); - if (user.id == null) { - // FIXME: Debug - // tslint:disable-next-line:no-console - console.trace("[DEBUG] Null user.id for mute"); - } - sendErrorMessage(pluginData, msg.channel, "Could not mute the user"); - } - - return; - } - - // Confirm the action to the moderator - let response: string; - if (args.time) { - if (muteResult.updatedExistingMute) { - response = asSingleLine(` - Updated **${renderUserUsername(user)}**'s - mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number}) - `); - } else { - response = asSingleLine(` - Muted **${renderUserUsername(user)}** - for ${timeUntilUnmute} (Case #${muteResult.case.case_number}) - `); - } - } else { - if (muteResult.updatedExistingMute) { - response = asSingleLine(` - Updated **${renderUserUsername(user)}**'s - mute to indefinite (Case #${muteResult.case.case_number}) - `); - } else { - response = asSingleLine(` - Muted **${renderUserUsername(user)}** - indefinitely (Case #${muteResult.case.case_number}) - `); - } - } - - if (muteResult.notifyResult.text) response += ` (${muteResult.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 deleted file mode 100644 index d70a219c..00000000 --- a/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { GuildMember, Message, User } from "discord.js"; -import humanizeDuration from "humanize-duration"; -import { GuildPluginData } from "knub"; -import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin"; -import { UnknownUser, asSingleLine, renderUserUsername } from "../../../utils"; -import { ModActionsPluginType } from "../types"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; - -export async function actualUnmuteCmd( - pluginData: GuildPluginData, - user: User | UnknownUser, - msg: Message, - args: { time?: number; reason?: string; mod?: GuildMember }, -) { - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.author; - let pp: User | null = null; - - if (args.mod) { - if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id }))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); - return; - } - - mod = args.mod.user; - pp = msg.author; - } - - const reason = args.reason ? formatReasonWithAttachments(args.reason, [...msg.attachments.values()]) : undefined; - - const mutesPlugin = pluginData.getPlugin(MutesPlugin); - const result = await mutesPlugin.unmuteUser(user.id, args.time, { - modId: mod.id, - ppId: pp ? pp.id : undefined, - reason, - }); - - if (!result) { - sendErrorMessage(pluginData, msg.channel, "User is not muted!"); - return; - } - - // Confirm the action to the moderator - if (args.time) { - const timeUntilUnmute = args.time && humanizeDuration(args.time); - sendSuccessMessage( - pluginData, - msg.channel, - asSingleLine(` - Unmuting **${renderUserUsername(user)}** - in ${timeUntilUnmute} (Case #${result.case.case_number}) - `), - ); - } else { - sendSuccessMessage( - pluginData, - msg.channel, - asSingleLine(` - Unmuted **${renderUserUsername(user)}** - (Case #${result.case.case_number}) - `), - ); - } -} diff --git a/backend/src/plugins/ModActions/functions/updateCase.ts b/backend/src/plugins/ModActions/functions/updateCase.ts index c19c9d06..d715e263 100644 --- a/backend/src/plugins/ModActions/functions/updateCase.ts +++ b/backend/src/plugins/ModActions/functions/updateCase.ts @@ -1,44 +1,53 @@ -import { Message } from "discord.js"; +import { Attachment, ChatInputCommandInteraction, TextBasedChannel, User } from "discord.js"; +import { GuildPluginData } from "knub"; import { CaseTypes } from "../../../data/CaseTypes"; import { Case } from "../../../data/entities/Case"; import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { ModActionsPluginType } from "../types"; import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; -export async function updateCase(pluginData, msg: Message, args) { - let theCase: Case | undefined; - if (args.caseNumber != null) { - theCase = await pluginData.state.cases.findByCaseNumber(args.caseNumber); +export async function updateCase( + pluginData: GuildPluginData, + context: TextBasedChannel | ChatInputCommandInteraction, + author: User, + caseNumber?: number, + note?: string, + attachments: Attachment[] = [], +) { + let theCase: Case | null; + if (caseNumber != null) { + theCase = await pluginData.state.cases.findByCaseNumber(caseNumber); } else { - theCase = await pluginData.state.cases.findLatestByModId(msg.author.id); + theCase = await pluginData.state.cases.findLatestByModId(author.id); } if (!theCase) { - sendErrorMessage(pluginData, msg.channel, "Case not found"); + sendErrorMessage(pluginData, context, "Case not found"); return; } - if (!args.note && msg.attachments.size === 0) { - sendErrorMessage(pluginData, msg.channel, "Text or attachment required"); + if (!note && attachments.length === 0) { + sendErrorMessage(pluginData, context, "Text or attachment required"); return; } - const note = formatReasonWithAttachments(args.note, [...msg.attachments.values()]); + const formattedNote = formatReasonWithAttachments(note ?? "", attachments); const casesPlugin = pluginData.getPlugin(CasesPlugin); await casesPlugin.createCaseNote({ caseId: theCase.id, - modId: msg.author.id, - body: note, + modId: author.id, + body: formattedNote, }); pluginData.getPlugin(LogsPlugin).logCaseUpdate({ - mod: msg.author, + mod: author, caseNumber: theCase.case_number, caseType: CaseTypes[theCase.type], - note, + note: formattedNote, }); - sendSuccessMessage(pluginData, msg.channel, `Case \`#${theCase.case_number}\` updated`); + sendSuccessMessage(pluginData, context, `Case \`#${theCase.case_number}\` updated`); } diff --git a/backend/src/plugins/ModActions/functions/warnMember.ts b/backend/src/plugins/ModActions/functions/warnMember.ts index 9bc0fda9..8f58f915 100644 --- a/backend/src/plugins/ModActions/functions/warnMember.ts +++ b/backend/src/plugins/ModActions/functions/warnMember.ts @@ -1,6 +1,7 @@ import { GuildMember, Snowflake } from "discord.js"; import { GuildPluginData } from "knub"; import { CaseTypes } from "../../../data/CaseTypes"; +import { isContextInteraction } from "../../../pluginUtils"; import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter"; import { UserNotificationResult, createUserNotificationError, notifyUser, resolveUser, ucfirst } from "../../../utils"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; @@ -39,20 +40,23 @@ export async function warnMember( } if (!notifyResult.success) { - if (warnOptions.retryPromptChannel && pluginData.guild.channels.resolve(warnOptions.retryPromptChannel.id)) { - const reply = await waitForButtonConfirm( - warnOptions.retryPromptChannel, - { content: "Failed to message the user. Log the warning anyway?" }, - { confirmText: "Yes", cancelText: "No", restrictToId: warnOptions.caseArgs?.modId }, - ); + const contextIsChannel = warnOptions.retryPromptContext && !isContextInteraction(warnOptions.retryPromptContext); + const isValidChannel = contextIsChannel && pluginData.guild.channels.resolve(warnOptions.retryPromptContext!.id); - if (!reply) { - return { - status: "failed", - error: "Failed to message user", - }; - } - } else { + if (!warnOptions.retryPromptContext || !isValidChannel) { + return { + status: "failed", + error: "Failed to message user", + }; + } + + const reply = await waitForButtonConfirm( + warnOptions.retryPromptContext, + { content: "Failed to message the user. Log the warning anyway?" }, + { confirmText: "Yes", cancelText: "No", restrictToId: warnOptions.caseArgs?.modId }, + ); + + if (!reply) { return { status: "failed", error: "Failed to message user", diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts index c6c1fe70..5a61c342 100644 --- a/backend/src/plugins/ModActions/types.ts +++ b/backend/src/plugins/ModActions/types.ts @@ -1,4 +1,4 @@ -import { GuildTextBasedChannel } from "discord.js"; +import { ChatInputCommandInteraction, TextBasedChannel } from "discord.js"; import { EventEmitter } from "events"; import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, guildPluginSlashGroup } from "knub"; @@ -127,7 +127,7 @@ export type WarnMemberNotifyRetryCallback = () => boolean | Promise; export interface WarnOptions { caseArgs?: Partial | null; contactMethods?: UserNotificationMethod[] | null; - retryPromptChannel?: GuildTextBasedChannel | null; + retryPromptContext?: TextBasedChannel | ChatInputCommandInteraction | null; isAutomodAction?: boolean; } diff --git a/backend/src/utils/createPaginatedMessage.ts b/backend/src/utils/createPaginatedMessage.ts index 18e8f3d0..d1075499 100644 --- a/backend/src/utils/createPaginatedMessage.ts +++ b/backend/src/utils/createPaginatedMessage.ts @@ -1,4 +1,5 @@ import { + ChatInputCommandInteraction, Client, Message, MessageCreateOptions, @@ -9,6 +10,7 @@ import { TextBasedChannel, User, } from "discord.js"; +import { sendContextResponse } from "../pluginUtils"; import { MINUTES, noop } from "../utils"; import { Awaitable } from "./typeUtils"; import Timeout = NodeJS.Timeout; @@ -27,14 +29,14 @@ const defaultOpts: PaginateMessageOpts = { export async function createPaginatedMessage( client: Client, - channel: TextBasedChannel | User, + context: TextBasedChannel | User | ChatInputCommandInteraction, totalPages: number, loadPageFn: LoadPageFn, opts: Partial = {}, ): Promise { const fullOpts = { ...defaultOpts, ...opts } as PaginateMessageOpts; const firstPageContent = await loadPageFn(1); - const message = await channel.send(firstPageContent); + const message = await sendContextResponse(context, firstPageContent); let page = 1; let pageLoadId = 0; // Used to avoid race conditions when rapidly switching pages diff --git a/backend/src/utils/multipleSlashOptions.ts b/backend/src/utils/multipleSlashOptions.ts new file mode 100644 index 00000000..9cdd2eca --- /dev/null +++ b/backend/src/utils/multipleSlashOptions.ts @@ -0,0 +1,20 @@ +import { AttachmentSlashCommandOption, slashOptions } from "knub"; + +type AttachmentSlashOptions = Omit; + +export function generateAttachmentSlashOptions(amount: number, options: AttachmentSlashOptions) { + return new Array(amount).fill(0).map((_, i) => { + return slashOptions.attachment({ + name: amount > 1 ? `${options.name}${i + 1}` : options.name, + description: options.description, + required: options.required ?? false, + }); + }); +} + +export function retrieveMultipleOptions(amount: number, options: any, name: string) { + return new Array(amount) + .fill(0) + .map((_, i) => options[amount > 1 ? `${name}${i + 1}` : name]) + .filter((a) => a); +} diff --git a/backend/src/utils/waitForInteraction.ts b/backend/src/utils/waitForInteraction.ts index 261da4da..64cab738 100644 --- a/backend/src/utils/waitForInteraction.ts +++ b/backend/src/utils/waitForInteraction.ts @@ -2,22 +2,26 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, - GuildTextBasedChannel, + ChatInputCommandInteraction, MessageActionRowComponentBuilder, MessageComponentInteraction, MessageCreateOptions, + TextBasedChannel, + User, } from "discord.js"; import moment from "moment"; import { v4 as uuidv4 } from "uuid"; +import { isContextInteraction } from "../pluginUtils"; import { noop } from "../utils"; export async function waitForButtonConfirm( - channel: GuildTextBasedChannel, + context: TextBasedChannel | User | ChatInputCommandInteraction, toPost: MessageCreateOptions, options?: WaitForOptions, ): Promise { return new Promise(async (resolve) => { - const idMod = `${channel.guild.id}-${moment.utc().valueOf()}`; + const contextIsInteraction = isContextInteraction(context); + const idMod = `${context.id}-${moment.utc().valueOf()}`; const row = new ActionRowBuilder().addComponents([ new ButtonBuilder() .setStyle(ButtonStyle.Success) @@ -29,7 +33,9 @@ export async function waitForButtonConfirm( .setLabel(options?.cancelText || "Cancel") .setCustomId(`cancelButton:${idMod}:${uuidv4()}`), ]); - const message = await channel.send({ ...toPost, components: [row] }); + const sendMethod = contextIsInteraction ? (context.replied ? "followUp" : "reply") : "send"; + const extraParameters = contextIsInteraction ? { fetchReply: true } : {}; + const message = await context[sendMethod]({ ...toPost, components: [row], ...extraParameters }); const collector = message.createMessageComponentCollector({ time: 10000 });