diff --git a/backend/src/api/guilds/misc.ts b/backend/src/api/guilds/misc.ts index 3ab72dd7..4b5e074a 100644 --- a/backend/src/api/guilds/misc.ts +++ b/backend/src/api/guilds/misc.ts @@ -1,6 +1,6 @@ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import express, { Request, Response } from "express"; -import jsYaml from "js-yaml"; +import { YAMLException } from "js-yaml"; import moment from "moment-timezone"; import { Queue } from "../../Queue.js"; import { validateGuildConfig } from "../../configValidator.js"; @@ -15,8 +15,6 @@ import { ObjectAliasError } from "../../utils/validateNoObjectAliases.js"; import { hasGuildPermission, requireGuildPermission } from "../permissions.js"; import { clientError, ok, serverError, unauthorized } from "../responses.js"; -const YAMLException = jsYaml.YAMLException; - const apiPermissionAssignments = new ApiPermissionAssignments(); const auditLog = new ApiAuditLog(); diff --git a/backend/src/api/start.ts b/backend/src/api/start.ts index 9fdf2976..b259d22a 100644 --- a/backend/src/api/start.ts +++ b/backend/src/api/start.ts @@ -28,10 +28,10 @@ app.use(multer().none()); const rootRouter = express.Router(); -initAuth(app); -initGuildsAPI(app); -initArchives(app); -initDocs(app); +initAuth(rootRouter); +initGuildsAPI(rootRouter); +initArchives(rootRouter); +initDocs(rootRouter); // Default route rootRouter.get("/", (req, res) => { diff --git a/backend/src/configValidator.ts b/backend/src/configValidator.ts index d5f59a98..557ac1a5 100644 --- a/backend/src/configValidator.ts +++ b/backend/src/configValidator.ts @@ -1,8 +1,7 @@ -import { ConfigValidationError, GuildPluginBlueprint, PluginConfigManager } from "knub"; -import moment from "moment-timezone"; +import { BaseConfig, ConfigValidationError, GuildPluginBlueprint, PluginConfigManager } from "knub"; import { ZodError } from "zod"; import { availableGuildPlugins } from "./plugins/availablePlugins.js"; -import { ZeppelinGuildConfig, zZeppelinGuildConfig } from "./types.js"; +import { zZeppelinGuildConfig } from "./types.js"; import { formatZodIssue } from "./utils/formatZodIssue.js"; const pluginNameToPlugin = new Map>(); @@ -16,14 +15,7 @@ export async function validateGuildConfig(config: any): Promise { return validationResult.error.issues.map(formatZodIssue).join("\n"); } - const guildConfig = config as ZeppelinGuildConfig; - - if (guildConfig.timezone) { - const validTimezones = moment.tz.names(); - if (!validTimezones.includes(guildConfig.timezone)) { - return `Invalid timezone: ${guildConfig.timezone}`; - } - } + const guildConfig = config as BaseConfig; if (guildConfig.plugins) { for (const [pluginName, pluginOptions] of Object.entries(guildConfig.plugins)) { diff --git a/backend/src/data/GuildCases.ts b/backend/src/data/GuildCases.ts index b04eea63..5d6c1ba0 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.js"; import { chunkArray } from "../utils.js"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; @@ -73,34 +74,69 @@ 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, + }, + }); + } + + async getRecentByUserId(userId: string, count: number, skip = 0): Promise { return this.cases.find({ relations: this.getRelations(), where: { guild_id: this.guildId, user_id: userId, }, + skip, + take: count, + order: { + case_number: "DESC", + }, }); } - async getTotalCasesByModId(modId: string): Promise { + 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/index.ts b/backend/src/index.ts index 6985ee84..fd926eb0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -44,15 +44,7 @@ import { availableGlobalPlugins, availableGuildPlugins } from "./plugins/availab import { setProfiler } from "./profiler.js"; import { logRateLimit } from "./rateLimitStats.js"; import { startUptimeCounter } from "./uptime.js"; -import { - MINUTES, - SECONDS, - errorMessage, - isDiscordAPIError, - isDiscordHTTPError, - sleep, - successMessage, -} from "./utils.js"; +import { MINUTES, SECONDS, errorMessage, isDiscordAPIError, isDiscordHTTPError, sleep, successMessage } from "./utils.js"; import { DecayingCounter } from "./utils/DecayingCounter.js"; import { enableProfiling } from "./utils/easyProfiler.js"; import { loadYamlSafely } from "./utils/loadYamlSafely.js"; @@ -324,9 +316,27 @@ connect().then(async () => { if (row) { try { const loaded = loadYamlSafely(row.config); + + if (loaded.success_emoji || loaded.error_emoji) { + const deprecatedKeys = [] as string[]; + const exampleConfig = `plugins:\n common:\n config:\n success_emoji: "👍"\n error_emoji: "👎"`; + + if (loaded.success_emoji) { + deprecatedKeys.push("success_emoji"); + } + + if (loaded.error_emoji) { + deprecatedKeys.push("error_emoji"); + } + + logger.warn(`Deprecated config properties found in "${key}": ${deprecatedKeys.join(", ")}`); + logger.warn(`You can now configure those emojis in the "common" plugin config\n${exampleConfig}`); + } + // Remove deprecated properties some may still have in their config delete loaded.success_emoji; delete loaded.error_emoji; + return loaded; } catch (err) { logger.error(`Error while loading config "${key}": ${err.message}`); diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts index 8cbc5b95..615ec7a0 100644 --- a/backend/src/pluginUtils.ts +++ b/backend/src/pluginUtils.ts @@ -3,18 +3,18 @@ */ import { + ChatInputCommandInteraction, GuildMember, + InteractionReplyOptions, Message, MessageCreateOptions, - MessageMentionOptions, PermissionsBitField, TextBasedChannel, + User, } from "discord.js"; import { AnyPluginData, BasePluginData, CommandContext, ExtendedMatchParams, GuildPluginData, helpers } from "knub"; -import { logger } from "./logger.js"; import { isStaff } from "./staff.js"; import { TZeppelinKnub } from "./types.js"; -import { errorMessage, successMessage } from "./utils.js"; import { Tail } from "./utils/typeUtils.js"; const { getMemberLevel } = helpers; @@ -49,46 +49,57 @@ export async function hasPermission( return helpers.hasPermission(config, permission); } -export async function sendSuccessMessage( - pluginData: AnyPluginData, - channel: TextBasedChannel, - body: string, - allowedMentions?: MessageMentionOptions, -): Promise { - const emoji = pluginData.fullConfig.success_emoji || undefined; - const formattedBody = successMessage(body, emoji); - const content: MessageCreateOptions = allowedMentions - ? { content: formattedBody, allowedMentions } - : { content: formattedBody }; - - return channel - .send({ ...content }) // Force line break - .catch((err) => { - const channelInfo = "guild" in channel ? `${channel.id} (${channel.guild.id})` : channel.id; - logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`); - return undefined; - }); +export function isContextInteraction( + context: TextBasedChannel | Message | User | ChatInputCommandInteraction, +): context is ChatInputCommandInteraction { + return "commandId" in context && !!context.commandId; } -export async function sendErrorMessage( - pluginData: AnyPluginData, - channel: TextBasedChannel, - body: string, - allowedMentions?: MessageMentionOptions, -): Promise { - const emoji = pluginData.fullConfig.error_emoji || undefined; - const formattedBody = errorMessage(body, emoji); - const content: MessageCreateOptions = allowedMentions - ? { content: formattedBody, allowedMentions } - : { content: formattedBody }; +export function isContextMessage( + context: TextBasedChannel | Message | User | ChatInputCommandInteraction, +): context is Message { + return "content" in context || "embeds" in context; +} - return channel - .send({ ...content }) // Force line break - .catch((err) => { - const channelInfo = "guild" in channel ? `${channel.id} (${channel.guild.id})` : channel.id; - logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`); - return undefined; - }); +export async function getContextChannel( + context: TextBasedChannel | Message | User | ChatInputCommandInteraction, +): Promise { + if (isContextInteraction(context)) { + // context is ChatInputCommandInteraction + return context.channel!; + } else if ("username" in context) { + // context is User + return await (context as User).createDM(); + } else if ("send" in context) { + // context is TextBaseChannel + return context as TextBasedChannel; + } else { + // context is Message + return context.channel; + } +} + +export async function sendContextResponse( + context: TextBasedChannel | Message | User | ChatInputCommandInteraction, + response: string | Omit | InteractionReplyOptions, +): Promise { + if (isContextInteraction(context)) { + const options = { ...(typeof response === "string" ? { content: response } : response), fetchReply: true }; + + return ( + context.replied + ? context.followUp(options) + : context.deferred + ? context.editReply(options) + : context.reply(options) + ) as Promise; + } + + if (typeof response !== "string" && "ephemeral" in response) { + delete response.ephemeral; + } + + return (await getContextChannel(context)).send(response as string | Omit); } export function getBaseUrl(pluginData: AnyPluginData) { diff --git a/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts b/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts index 6c03d640..915eab26 100644 --- a/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts +++ b/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts @@ -1,6 +1,7 @@ import { PluginOptions, guildPlugin } from "knub"; import { GuildAutoReactions } from "../../data/GuildAutoReactions.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { DisableAutoReactionsCmd } from "./commands/DisableAutoReactionsCmd.js"; import { NewAutoReactionsCmd } from "./commands/NewAutoReactionsCmd.js"; @@ -50,4 +51,8 @@ export const AutoReactionsPlugin = guildPlugin()({ state.autoReactions = GuildAutoReactions.getGuildInstance(guild.id); state.cache = new Map(); }, + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/AutoReactions/commands/DisableAutoReactionsCmd.ts b/backend/src/plugins/AutoReactions/commands/DisableAutoReactionsCmd.ts index fc950aa7..eba1f92b 100644 --- a/backend/src/plugins/AutoReactions/commands/DisableAutoReactionsCmd.ts +++ b/backend/src/plugins/AutoReactions/commands/DisableAutoReactionsCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { autoReactionsCmd } from "../types.js"; export const DisableAutoReactionsCmd = autoReactionsCmd({ @@ -14,12 +13,12 @@ export const DisableAutoReactionsCmd = autoReactionsCmd({ async run({ message: msg, args, pluginData }) { const autoReaction = await pluginData.state.autoReactions.getForChannel(args.channelId); if (!autoReaction) { - sendErrorMessage(pluginData, msg.channel, `Auto-reactions aren't enabled in <#${args.channelId}>`); + void pluginData.state.common.sendErrorMessage(msg, `Auto-reactions aren't enabled in <#${args.channelId}>`); return; } await pluginData.state.autoReactions.removeFromChannel(args.channelId); pluginData.state.cache.delete(args.channelId); - sendSuccessMessage(pluginData, msg.channel, `Auto-reactions disabled in <#${args.channelId}>`); + void pluginData.state.common.sendSuccessMessage(msg, `Auto-reactions disabled in <#${args.channelId}>`); }, }); diff --git a/backend/src/plugins/AutoReactions/commands/NewAutoReactionsCmd.ts b/backend/src/plugins/AutoReactions/commands/NewAutoReactionsCmd.ts index 6eed443a..c1250b53 100644 --- a/backend/src/plugins/AutoReactions/commands/NewAutoReactionsCmd.ts +++ b/backend/src/plugins/AutoReactions/commands/NewAutoReactionsCmd.ts @@ -1,6 +1,5 @@ import { PermissionsBitField } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { canUseEmoji, customEmojiRegex, isEmoji } from "../../../utils.js"; import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; @@ -25,9 +24,8 @@ export const NewAutoReactionsCmd = autoReactionsCmd({ const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; const missingPermissions = getMissingChannelPermissions(me, args.channel, requiredPermissions); if (missingPermissions) { - sendErrorMessage( - pluginData, - msg.channel, + pluginData.state.common.sendErrorMessage( + msg, `Cannot set auto-reactions for that channel. ${missingPermissionError(missingPermissions)}`, ); return; @@ -35,7 +33,7 @@ export const NewAutoReactionsCmd = autoReactionsCmd({ for (const reaction of args.reactions) { if (!isEmoji(reaction)) { - sendErrorMessage(pluginData, msg.channel, "One or more of the specified reactions were invalid!"); + void pluginData.state.common.sendErrorMessage(msg, "One or more of the specified reactions were invalid!"); return; } @@ -45,7 +43,10 @@ export const NewAutoReactionsCmd = autoReactionsCmd({ if (customEmojiMatch) { // Custom emoji if (!canUseEmoji(pluginData.client, customEmojiMatch[2])) { - sendErrorMessage(pluginData, msg.channel, "I can only use regular emojis and custom emojis from this server"); + pluginData.state.common.sendErrorMessage( + msg, + "I can only use regular emojis and custom emojis from this server", + ); return; } @@ -60,6 +61,6 @@ export const NewAutoReactionsCmd = autoReactionsCmd({ await pluginData.state.autoReactions.set(args.channel.id, finalReactions); pluginData.state.cache.delete(args.channel.id); - sendSuccessMessage(pluginData, msg.channel, `Auto-reactions set for <#${args.channel.id}>`); + void pluginData.state.common.sendSuccessMessage(msg, `Auto-reactions set for <#${args.channel.id}>`); }, }); diff --git a/backend/src/plugins/AutoReactions/types.ts b/backend/src/plugins/AutoReactions/types.ts index f794b246..0a4c4db0 100644 --- a/backend/src/plugins/AutoReactions/types.ts +++ b/backend/src/plugins/AutoReactions/types.ts @@ -1,9 +1,10 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { GuildAutoReactions } from "../../data/GuildAutoReactions.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { AutoReaction } from "../../data/entities/AutoReaction.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zAutoReactionsConfig = z.strictObject({ can_manage: z.boolean(), @@ -16,6 +17,7 @@ export interface AutoReactionsPluginType extends BasePluginType { savedMessages: GuildSavedMessages; autoReactions: GuildAutoReactions; cache: Map; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts index 873f10fe..de38d544 100644 --- a/backend/src/plugins/Automod/AutomodPlugin.ts +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -8,6 +8,7 @@ import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners.js"; import { MINUTES, SECONDS } from "../../utils.js"; import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap.js"; import { unregisterEventListenersFromMap } from "../../utils/unregisterEventListenersFromMap.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { CountersPlugin } from "../Counters/CountersPlugin.js"; import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; @@ -117,6 +118,10 @@ export const AutomodPlugin = guildPlugin()({ state.cachedAntiraidLevel = await state.antiraidLevels.get(); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + async afterLoad(pluginData) { const { state } = pluginData; diff --git a/backend/src/plugins/Automod/actions/ban.ts b/backend/src/plugins/Automod/actions/ban.ts index 62a27e15..d64de15c 100644 --- a/backend/src/plugins/Automod/actions/ban.ts +++ b/backend/src/plugins/Automod/actions/ban.ts @@ -47,6 +47,7 @@ export const BanAction = automodAction({ await modActions.banUserId( userId, reason, + reason, { contactMethods, caseArgs, diff --git a/backend/src/plugins/Automod/actions/kick.ts b/backend/src/plugins/Automod/actions/kick.ts index a1d7ef7c..7abed550 100644 --- a/backend/src/plugins/Automod/actions/kick.ts +++ b/backend/src/plugins/Automod/actions/kick.ts @@ -33,7 +33,7 @@ export const KickAction = automodAction({ const modActions = pluginData.getPlugin(ModActionsPlugin); for (const member of membersToKick) { if (!member) continue; - await modActions.kickMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true }); + await modActions.kickMember(member, reason, reason, { contactMethods, caseArgs, isAutomodAction: true }); } }, }); diff --git a/backend/src/plugins/Automod/actions/mute.ts b/backend/src/plugins/Automod/actions/mute.ts index 3708e71e..abb87916 100644 --- a/backend/src/plugins/Automod/actions/mute.ts +++ b/backend/src/plugins/Automod/actions/mute.ts @@ -57,6 +57,7 @@ export const MuteAction = automodAction({ userId, duration, reason, + reason, { contactMethods, caseArgs, isAutomodAction: true }, rolesToRemove, rolesToRestore, diff --git a/backend/src/plugins/Automod/actions/warn.ts b/backend/src/plugins/Automod/actions/warn.ts index 9c36f637..6e58d494 100644 --- a/backend/src/plugins/Automod/actions/warn.ts +++ b/backend/src/plugins/Automod/actions/warn.ts @@ -33,7 +33,7 @@ export const WarnAction = automodAction({ const modActions = pluginData.getPlugin(ModActionsPlugin); for (const member of membersToWarn) { if (!member) continue; - await modActions.warnMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true }); + await modActions.warnMember(member, reason, reason, { contactMethods, caseArgs, isAutomodAction: true }); } }, }); diff --git a/backend/src/plugins/Automod/commands/AntiraidClearCmd.ts b/backend/src/plugins/Automod/commands/AntiraidClearCmd.ts index 187696ea..7686fd8c 100644 --- a/backend/src/plugins/Automod/commands/AntiraidClearCmd.ts +++ b/backend/src/plugins/Automod/commands/AntiraidClearCmd.ts @@ -1,5 +1,4 @@ import { guildPluginMessageCommand } from "knub"; -import { sendSuccessMessage } from "../../../pluginUtils.js"; import { setAntiraidLevel } from "../functions/setAntiraidLevel.js"; import { AutomodPluginType } from "../types.js"; @@ -9,6 +8,6 @@ export const AntiraidClearCmd = guildPluginMessageCommand()({ async run({ pluginData, message }) { await setAntiraidLevel(pluginData, null, message.author); - sendSuccessMessage(pluginData, message.channel, "Anti-raid turned **off**"); + void pluginData.state.common.sendSuccessMessage(message, "Anti-raid turned **off**"); }, }); diff --git a/backend/src/plugins/Automod/commands/SetAntiraidCmd.ts b/backend/src/plugins/Automod/commands/SetAntiraidCmd.ts index cd942ea0..1b8beaf2 100644 --- a/backend/src/plugins/Automod/commands/SetAntiraidCmd.ts +++ b/backend/src/plugins/Automod/commands/SetAntiraidCmd.ts @@ -1,6 +1,5 @@ import { guildPluginMessageCommand } from "knub"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { setAntiraidLevel } from "../functions/setAntiraidLevel.js"; import { AutomodPluginType } from "../types.js"; @@ -15,11 +14,11 @@ export const SetAntiraidCmd = guildPluginMessageCommand()({ async run({ pluginData, message, args }) { const config = pluginData.config.get(); if (!config.antiraid_levels.includes(args.level)) { - sendErrorMessage(pluginData, message.channel, "Unknown anti-raid level"); + pluginData.state.common.sendErrorMessage(message, "Unknown anti-raid level"); return; } await setAntiraidLevel(pluginData, args.level, message.author); - sendSuccessMessage(pluginData, message.channel, `Anti-raid level set to **${args.level}**`); + pluginData.state.common.sendSuccessMessage(message, `Anti-raid level set to **${args.level}**`); }, }); diff --git a/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts b/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts index f2d9baf5..aeea867d 100644 --- a/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts +++ b/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts @@ -1,6 +1,6 @@ import { guildPluginEventListener } from "knub"; -import diff from "lodash/difference.js"; -import isEqual from "lodash/isEqual.js"; +import diff from "lodash.difference"; +import isEqual from "lodash.isequal"; import { runAutomod } from "../functions/runAutomod.js"; import { AutomodContext, AutomodPluginType } from "../types.js"; diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts index 25d8cea6..565f0470 100644 --- a/backend/src/plugins/Automod/types.ts +++ b/backend/src/plugins/Automod/types.ts @@ -1,5 +1,5 @@ import { GuildMember, GuildTextBasedChannel, PartialGuildMember, ThreadChannel, User } from "discord.js"; -import { BasePluginType, CooldownManager } from "knub"; +import { BasePluginType, CooldownManager, pluginUtils } from "knub"; import z from "zod"; import { Queue } from "../../Queue.js"; import { RegExpRunner } from "../../RegExpRunner.js"; @@ -9,6 +9,7 @@ import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { SavedMessage } from "../../data/entities/SavedMessage.js"; import { entries, zBoundedRecord, zDelayString } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { CounterEvents } from "../Counters/types.js"; import { ModActionType, ModActionsEvents } from "../ModActions/types.js"; import { MutesEvents } from "../Mutes/types.js"; @@ -140,6 +141,8 @@ export interface AutomodPluginType extends BasePluginType { modActionsListeners: Map; mutesListeners: Map; + + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/BotControl/BotControlPlugin.ts b/backend/src/plugins/BotControl/BotControlPlugin.ts index f2224e6a..2c504751 100644 --- a/backend/src/plugins/BotControl/BotControlPlugin.ts +++ b/backend/src/plugins/BotControl/BotControlPlugin.ts @@ -4,7 +4,6 @@ import { AllowedGuilds } from "../../data/AllowedGuilds.js"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments.js"; import { Configs } from "../../data/Configs.js"; import { GuildArchives } from "../../data/GuildArchives.js"; -import { sendSuccessMessage } from "../../pluginUtils.js"; import { getActiveReload, resetActiveReload } from "./activeReload.js"; import { AddDashboardUserCmd } from "./commands/AddDashboardUserCmd.js"; import { AddServerFromInviteCmd } from "./commands/AddServerFromInviteCmd.js"; @@ -77,7 +76,7 @@ export const BotControlPlugin = globalPlugin()({ if (guild) { const channel = guild.channels.cache.get(channelId as Snowflake); if (channel instanceof TextChannel) { - sendSuccessMessage(pluginData, channel, "Global plugins reloaded!"); + void channel.send("Global plugins reloaded!"); } } } diff --git a/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts b/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts index 42cfc25d..200f3d16 100644 --- a/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts +++ b/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts @@ -1,6 +1,6 @@ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { renderUsername } from "../../../utils.js"; import { botControlCmd } from "../types.js"; @@ -19,7 +19,7 @@ export const AddDashboardUserCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const guild = await pluginData.state.allowedGuilds.find(args.guildId); if (!guild) { - sendErrorMessage(pluginData, msg.channel, "Server is not using Zeppelin"); + void msg.channel.send("Server is not using Zeppelin"); return; } @@ -36,10 +36,7 @@ export const AddDashboardUserCmd = botControlCmd({ } const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`)`); - sendSuccessMessage( - pluginData, - msg.channel, - `The following users were given dashboard access for **${guild.name}**:\n\n${userNameList}`, - ); + + msg.channel.send(`The following users were given dashboard access for **${guild.name}**:\n\n${userNameList}`); }, }); diff --git a/backend/src/plugins/BotControl/commands/AddServerFromInviteCmd.ts b/backend/src/plugins/BotControl/commands/AddServerFromInviteCmd.ts index 4bda1a9d..04da9b4d 100644 --- a/backend/src/plugins/BotControl/commands/AddServerFromInviteCmd.ts +++ b/backend/src/plugins/BotControl/commands/AddServerFromInviteCmd.ts @@ -1,7 +1,6 @@ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { DBDateFormat, isGuildInvite, resolveInvite } from "../../../utils.js"; import { isEligible } from "../functions/isEligible.js"; import { botControlCmd } from "../types.js"; @@ -18,19 +17,19 @@ export const AddServerFromInviteCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const invite = await resolveInvite(pluginData.client, args.inviteCode, true); if (!invite || !isGuildInvite(invite)) { - sendErrorMessage(pluginData, msg.channel, "Could not resolve invite"); // :D + void msg.channel.send("Could not resolve invite"); // :D return; } const existing = await pluginData.state.allowedGuilds.find(invite.guild.id); if (existing) { - sendErrorMessage(pluginData, msg.channel, "Server is already allowed!"); + void msg.channel.send("Server is already allowed!"); return; } const { result, explanation } = await isEligible(pluginData, args.user, invite); if (!result) { - sendErrorMessage(pluginData, msg.channel, `Could not add server because it's not eligible: ${explanation}`); + msg.channel.send(`Could not add server because it's not eligible: ${explanation}`); return; } @@ -51,6 +50,6 @@ export const AddServerFromInviteCmd = botControlCmd({ ); } - sendSuccessMessage(pluginData, msg.channel, "Server was eligible and is now allowed to use Zeppelin!"); + msg.channel.send("Server was eligible and is now allowed to use Zeppelin!"); }, }); diff --git a/backend/src/plugins/BotControl/commands/AllowServerCmd.ts b/backend/src/plugins/BotControl/commands/AllowServerCmd.ts index 6dd0751f..42ffb715 100644 --- a/backend/src/plugins/BotControl/commands/AllowServerCmd.ts +++ b/backend/src/plugins/BotControl/commands/AllowServerCmd.ts @@ -1,7 +1,7 @@ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { DBDateFormat, isSnowflake } from "../../../utils.js"; import { botControlCmd } from "../types.js"; @@ -20,17 +20,17 @@ export const AllowServerCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const existing = await pluginData.state.allowedGuilds.find(args.guildId); if (existing) { - sendErrorMessage(pluginData, msg.channel, "Server is already allowed!"); + void msg.channel.send("Server is already allowed!"); return; } if (!isSnowflake(args.guildId)) { - sendErrorMessage(pluginData, msg.channel, "Invalid server ID!"); + void msg.channel.send("Invalid server ID!"); return; } if (args.userId && !isSnowflake(args.userId)) { - sendErrorMessage(pluginData, msg.channel, "Invalid user ID!"); + void msg.channel.send("Invalid user ID!"); return; } @@ -51,6 +51,6 @@ export const AllowServerCmd = botControlCmd({ ); } - sendSuccessMessage(pluginData, msg.channel, "Server is now allowed to use Zeppelin!"); + void msg.channel.send("Server is now allowed to use Zeppelin!"); }, }); diff --git a/backend/src/plugins/BotControl/commands/ChannelToServerCmd.ts b/backend/src/plugins/BotControl/commands/ChannelToServerCmd.ts index 927a75ab..a4f53675 100644 --- a/backend/src/plugins/BotControl/commands/ChannelToServerCmd.ts +++ b/backend/src/plugins/BotControl/commands/ChannelToServerCmd.ts @@ -1,5 +1,5 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { botControlCmd } from "../types.js"; export const ChannelToServerCmd = botControlCmd({ @@ -16,7 +16,7 @@ export const ChannelToServerCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const channel = pluginData.client.channels.cache.get(args.channelId); if (!channel) { - sendErrorMessage(pluginData, msg.channel, "Channel not found in cache!"); + void msg.channel.send("Channel not found in cache!"); return; } diff --git a/backend/src/plugins/BotControl/commands/DisallowServerCmd.ts b/backend/src/plugins/BotControl/commands/DisallowServerCmd.ts index 17515a8b..8a753392 100644 --- a/backend/src/plugins/BotControl/commands/DisallowServerCmd.ts +++ b/backend/src/plugins/BotControl/commands/DisallowServerCmd.ts @@ -1,6 +1,6 @@ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { noop } from "../../../utils.js"; import { botControlCmd } from "../types.js"; @@ -18,7 +18,7 @@ export const DisallowServerCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const existing = await pluginData.state.allowedGuilds.find(args.guildId); if (!existing) { - sendErrorMessage(pluginData, msg.channel, "That server is not allowed in the first place!"); + void msg.channel.send("That server is not allowed in the first place!"); return; } @@ -27,6 +27,6 @@ export const DisallowServerCmd = botControlCmd({ .get(args.guildId as Snowflake) ?.leave() .catch(noop); - sendSuccessMessage(pluginData, msg.channel, "Server removed!"); + void msg.channel.send("Server removed!"); }, }); diff --git a/backend/src/plugins/BotControl/commands/EligibleCmd.ts b/backend/src/plugins/BotControl/commands/EligibleCmd.ts index 136266fd..0f753f7b 100644 --- a/backend/src/plugins/BotControl/commands/EligibleCmd.ts +++ b/backend/src/plugins/BotControl/commands/EligibleCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { isGuildInvite, resolveInvite } from "../../../utils.js"; import { isEligible } from "../functions/isEligible.js"; import { botControlCmd } from "../types.js"; @@ -16,17 +15,17 @@ export const EligibleCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const invite = await resolveInvite(pluginData.client, args.inviteCode, true); if (!invite || !isGuildInvite(invite)) { - sendErrorMessage(pluginData, msg.channel, "Could not resolve invite"); + void msg.channel.send("Could not resolve invite"); return; } const { result, explanation } = await isEligible(pluginData, args.user, invite); if (result) { - sendSuccessMessage(pluginData, msg.channel, `Server is eligible: ${explanation}`); + void msg.channel.send(`Server is eligible: ${explanation}`); return; } - sendErrorMessage(pluginData, msg.channel, `Server is **NOT** eligible: ${explanation}`); + void msg.channel.send(`Server is **NOT** eligible: ${explanation}`); }, }); diff --git a/backend/src/plugins/BotControl/commands/LeaveServerCmd.ts b/backend/src/plugins/BotControl/commands/LeaveServerCmd.ts index 02e026fd..7f3a3578 100644 --- a/backend/src/plugins/BotControl/commands/LeaveServerCmd.ts +++ b/backend/src/plugins/BotControl/commands/LeaveServerCmd.ts @@ -1,6 +1,6 @@ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { botControlCmd } from "../types.js"; export const LeaveServerCmd = botControlCmd({ @@ -16,7 +16,7 @@ export const LeaveServerCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { if (!pluginData.client.guilds.cache.has(args.guildId as Snowflake)) { - sendErrorMessage(pluginData, msg.channel, "I am not in that guild"); + void msg.channel.send("I am not in that guild"); return; } @@ -26,10 +26,10 @@ export const LeaveServerCmd = botControlCmd({ try { await pluginData.client.guilds.cache.get(args.guildId as Snowflake)?.leave(); } catch (e) { - sendErrorMessage(pluginData, msg.channel, `Failed to leave guild: ${e.message}`); + void msg.channel.send(`Failed to leave guild: ${e.message}`); return; } - sendSuccessMessage(pluginData, msg.channel, `Left guild **${guildName}**`); + void msg.channel.send(`Left guild **${guildName}**`); }, }); diff --git a/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts b/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts index 540459ec..c75e520a 100644 --- a/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts +++ b/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts @@ -1,7 +1,6 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { AllowedGuild } from "../../../data/entities/AllowedGuild.js"; import { ApiPermissionAssignment } from "../../../data/entities/ApiPermissionAssignment.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { renderUsername, resolveUser } from "../../../utils.js"; import { botControlCmd } from "../types.js"; @@ -16,7 +15,7 @@ export const ListDashboardPermsCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { if (!args.user && !args.guildId) { - sendErrorMessage(pluginData, msg.channel, "Must specify at least guildId, user, or both."); + void msg.channel.send("Must specify at least guildId, user, or both."); return; } @@ -24,7 +23,7 @@ export const ListDashboardPermsCmd = botControlCmd({ if (args.guildId) { guild = await pluginData.state.allowedGuilds.find(args.guildId); if (!guild) { - sendErrorMessage(pluginData, msg.channel, "Server is not using Zeppelin"); + void msg.channel.send("Server is not using Zeppelin"); return; } } @@ -33,7 +32,7 @@ export const ListDashboardPermsCmd = botControlCmd({ if (args.user) { existingUserAssignment = await pluginData.state.apiPermissionAssignments.getByUserId(args.user.id); if (existingUserAssignment.length === 0) { - sendErrorMessage(pluginData, msg.channel, "The user has no assigned permissions."); + void msg.channel.send("The user has no assigned permissions."); return; } } @@ -54,11 +53,7 @@ export const ListDashboardPermsCmd = botControlCmd({ } if (finalMessage === "") { - sendErrorMessage( - pluginData, - msg.channel, - `The user ${userInfo} has no assigned permissions on the specified server.`, - ); + msg.channel.send(`The user ${userInfo} has no assigned permissions on the specified server.`); return; } // Else display all users that have permissions on the specified guild @@ -67,7 +62,7 @@ export const ListDashboardPermsCmd = botControlCmd({ const existingGuildAssignment = await pluginData.state.apiPermissionAssignments.getByGuildId(guild.id); if (existingGuildAssignment.length === 0) { - sendErrorMessage(pluginData, msg.channel, `The server ${guildInfo} has no assigned permissions.`); + msg.channel.send(`The server ${guildInfo} has no assigned permissions.`); return; } @@ -80,6 +75,9 @@ export const ListDashboardPermsCmd = botControlCmd({ } } - await sendSuccessMessage(pluginData, msg.channel, finalMessage.trim(), {}); + await msg.channel.send({ + content: finalMessage.trim(), + allowedMentions: {}, + }); }, }); diff --git a/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts b/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts index cc23caa4..59171660 100644 --- a/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts +++ b/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { renderUsername, resolveUser } from "../../../utils.js"; import { botControlCmd } from "../types.js"; @@ -14,7 +13,7 @@ export const ListDashboardUsersCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const guild = await pluginData.state.allowedGuilds.find(args.guildId); if (!guild) { - sendErrorMessage(pluginData, msg.channel, "Server is not using Zeppelin"); + void msg.channel.send("Server is not using Zeppelin"); return; } @@ -30,11 +29,9 @@ export const ListDashboardUsersCmd = botControlCmd({ `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`): ${permission.permissions.join(", ")}`, ); - sendSuccessMessage( - pluginData, - msg.channel, - `The following users have dashboard access for **${guild.name}**:\n\n${userNameList.join("\n")}`, - {}, - ); + msg.channel.send({ + content: `The following users have dashboard access for **${guild.name}**:\n\n${userNameList.join("\n")}`, + allowedMentions: {}, + }); }, }); diff --git a/backend/src/plugins/BotControl/commands/RateLimitPerformanceCmd.ts b/backend/src/plugins/BotControl/commands/RateLimitPerformanceCmd.ts index c0c5bef1..39675bb9 100644 --- a/backend/src/plugins/BotControl/commands/RateLimitPerformanceCmd.ts +++ b/backend/src/plugins/BotControl/commands/RateLimitPerformanceCmd.ts @@ -1,6 +1,6 @@ import moment from "moment-timezone"; import { GuildArchives } from "../../../data/GuildArchives.js"; -import { getBaseUrl, sendSuccessMessage } from "../../../pluginUtils.js"; +import { getBaseUrl } from "../../../pluginUtils.js"; import { getRateLimitStats } from "../../../rateLimitStats.js"; import { botControlCmd } from "../types.js"; @@ -13,7 +13,7 @@ export const RateLimitPerformanceCmd = botControlCmd({ async run({ pluginData, message: msg }) { const logItems = getRateLimitStats(); if (logItems.length === 0) { - sendSuccessMessage(pluginData, msg.channel, `No rate limits hit`); + void msg.channel.send(`No rate limits hit`); return; } diff --git a/backend/src/plugins/BotControl/commands/ReloadGlobalPluginsCmd.ts b/backend/src/plugins/BotControl/commands/ReloadGlobalPluginsCmd.ts index 6c2b921f..c95ea1dd 100644 --- a/backend/src/plugins/BotControl/commands/ReloadGlobalPluginsCmd.ts +++ b/backend/src/plugins/BotControl/commands/ReloadGlobalPluginsCmd.ts @@ -1,4 +1,4 @@ -import { isStaffPreFilter, sendErrorMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { getActiveReload, setActiveReload } from "../activeReload.js"; import { botControlCmd } from "../types.js"; @@ -14,7 +14,7 @@ export const ReloadGlobalPluginsCmd = botControlCmd({ const guildId = "guild" in message.channel ? message.channel.guild.id : null; if (!guildId) { - sendErrorMessage(pluginData, message.channel, "This command can only be used in a server"); + void message.channel.send("This command can only be used in a server"); return; } diff --git a/backend/src/plugins/BotControl/commands/ReloadServerCmd.ts b/backend/src/plugins/BotControl/commands/ReloadServerCmd.ts index a8b593de..b7c94bb3 100644 --- a/backend/src/plugins/BotControl/commands/ReloadServerCmd.ts +++ b/backend/src/plugins/BotControl/commands/ReloadServerCmd.ts @@ -1,6 +1,6 @@ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { botControlCmd } from "../types.js"; export const ReloadServerCmd = botControlCmd({ @@ -16,18 +16,18 @@ export const ReloadServerCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { if (!pluginData.client.guilds.cache.has(args.guildId as Snowflake)) { - sendErrorMessage(pluginData, msg.channel, "I am not in that guild"); + void msg.channel.send("I am not in that guild"); return; } try { await pluginData.getKnubInstance().reloadGuild(args.guildId); } catch (e) { - sendErrorMessage(pluginData, msg.channel, `Failed to reload guild: ${e.message}`); + void msg.channel.send(`Failed to reload guild: ${e.message}`); return; } const guild = await pluginData.client.guilds.fetch(args.guildId as Snowflake); - sendSuccessMessage(pluginData, msg.channel, `Reloaded guild **${guild?.name || "???"}**`); + void msg.channel.send(`Reloaded guild **${guild?.name || "???"}**`); }, }); diff --git a/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts b/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts index 86066734..8b5ad44e 100644 --- a/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts +++ b/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts @@ -1,5 +1,5 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { renderUsername } from "../../../utils.js"; import { botControlCmd } from "../types.js"; @@ -18,7 +18,7 @@ export const RemoveDashboardUserCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const guild = await pluginData.state.allowedGuilds.find(args.guildId); if (!guild) { - sendErrorMessage(pluginData, msg.channel, "Server is not using Zeppelin"); + void msg.channel.send("Server is not using Zeppelin"); return; } @@ -35,10 +35,7 @@ export const RemoveDashboardUserCmd = botControlCmd({ } const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`)`); - sendSuccessMessage( - pluginData, - msg.channel, - `The following users were removed from the dashboard for **${guild.name}**:\n\n${userNameList}`, - ); + + msg.channel.send(`The following users were removed from the dashboard for **${guild.name}**:\n\n${userNameList}`); }, }); diff --git a/backend/src/plugins/Cases/functions/getRecentCasesByMod.ts b/backend/src/plugins/Cases/functions/getRecentCasesByMod.ts index 6333b1e6..80edd822 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.js"; import { CasesPluginType } from "../types.js"; @@ -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 a2401cbc..ca58c10d 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.js"; import { CasesPluginType } from "../types.js"; -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/Censor/util/applyFiltersToMsg.ts b/backend/src/plugins/Censor/util/applyFiltersToMsg.ts index e1cf25a0..2663f9e6 100644 --- a/backend/src/plugins/Censor/util/applyFiltersToMsg.ts +++ b/backend/src/plugins/Censor/util/applyFiltersToMsg.ts @@ -1,17 +1,11 @@ import { Invite } from "discord.js"; import escapeStringRegexp from "escape-string-regexp"; import { GuildPluginData } from "knub"; -import cloneDeep from "lodash/cloneDeep.js"; +import cloneDeep from "lodash.clonedeep"; import { allowTimeout } from "../../../RegExpRunner.js"; import { ZalgoRegex } from "../../../data/Zalgo.js"; import { ISavedMessageEmbedData, SavedMessage } from "../../../data/entities/SavedMessage.js"; -import { - getInviteCodesInString, - getUrlsInString, - isGuildInvite, - resolveInvite, - resolveMember, -} from "../../../utils.js"; +import { getInviteCodesInString, getUrlsInString, isGuildInvite, resolveInvite, resolveMember } from "../../../utils.js"; import { CensorPluginType } from "../types.js"; import { censorMessage } from "./censorMessage.js"; diff --git a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts index 5423a747..d3885f24 100644 --- a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts +++ b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts @@ -1,5 +1,6 @@ import { guildPlugin } from "knub"; import z from "zod"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { ArchiveChannelCmd } from "./commands/ArchiveChannelCmd.js"; import { ChannelArchiverPluginType } from "./types.js"; @@ -14,4 +15,8 @@ export const ChannelArchiverPlugin = guildPlugin()({ messageCommands: [ ArchiveChannelCmd, ], + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts index 8edd19fe..138a3da9 100644 --- a/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts +++ b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts @@ -1,7 +1,7 @@ import { Snowflake } from "discord.js"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isOwner, sendErrorMessage } from "../../../pluginUtils.js"; +import { isOwner } from "../../../pluginUtils.js"; import { SECONDS, confirm, noop, renderUsername } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { rehostAttachment } from "../rehostAttachment.js"; @@ -32,12 +32,12 @@ export const ArchiveChannelCmd = channelArchiverCmd({ async run({ message: msg, args, pluginData }) { if (!args["attachment-channel"]) { - const confirmed = await confirm(msg.channel, msg.author.id, { + const confirmed = await confirm(msg, msg.author.id, { content: "No `-attachment-channel` specified. Continue? Attachments will not be available in the log if their message is deleted.", }); if (!confirmed) { - sendErrorMessage(pluginData, msg.channel, "Canceled"); + void pluginData.state.common.sendErrorMessage(msg, "Canceled"); return; } } diff --git a/backend/src/plugins/ChannelArchiver/types.ts b/backend/src/plugins/ChannelArchiver/types.ts index 024edf3d..bf264775 100644 --- a/backend/src/plugins/ChannelArchiver/types.ts +++ b/backend/src/plugins/ChannelArchiver/types.ts @@ -1,5 +1,10 @@ -import { BasePluginType, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "knub"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; -export interface ChannelArchiverPluginType extends BasePluginType {} +export interface ChannelArchiverPluginType extends BasePluginType { + state: { + common: pluginUtils.PluginPublicInterface; + }; +} export const channelArchiverCmd = guildPluginMessageCommand(); diff --git a/backend/src/plugins/Common/CommonPlugin.ts b/backend/src/plugins/Common/CommonPlugin.ts new file mode 100644 index 00000000..042a72e0 --- /dev/null +++ b/backend/src/plugins/Common/CommonPlugin.ts @@ -0,0 +1,153 @@ +import { + Attachment, + ChatInputCommandInteraction, + Message, + MessageCreateOptions, + MessageMentionOptions, + ModalSubmitInteraction, + TextBasedChannel, + User, +} from "discord.js"; +import { PluginOptions, guildPlugin } from "knub"; +import { logger } from "../../logger.js"; +import { isContextInteraction, sendContextResponse } from "../../pluginUtils.js"; +import { errorMessage, successMessage } from "../../utils.js"; +import { getErrorEmoji, getSuccessEmoji } from "./functions/getEmoji.js"; +import { CommonPluginType, zCommonConfig } from "./types.js"; + +const defaultOptions: PluginOptions = { + config: { + success_emoji: "✅", + error_emoji: "❌", + attachment_storing_channel: null, + }, +}; + +export const CommonPlugin = guildPlugin()({ + name: "common", + dependencies: () => [], + configParser: (input) => zCommonConfig.parse(input), + defaultOptions, + public(pluginData) { + return { + getSuccessEmoji, + getErrorEmoji, + + sendSuccessMessage: async ( + context: TextBasedChannel | Message | User | ChatInputCommandInteraction, + body: string, + allowedMentions?: MessageMentionOptions, + responseInteraction?: ModalSubmitInteraction, + ephemeral = true, + ): Promise => { + const emoji = getSuccessEmoji(pluginData); + const formattedBody = successMessage(body, emoji); + const content: MessageCreateOptions = allowedMentions + ? { content: formattedBody, allowedMentions } + : { content: formattedBody }; + + if (responseInteraction) { + await responseInteraction + .editReply({ content: formattedBody, embeds: [], components: [] }) + .catch((err) => logger.error(`Interaction reply failed: ${err}`)); + + return; + } + + if (!isContextInteraction(context)) { + // noinspection TypeScriptValidateJSTypes + return sendContextResponse(context, { ...content }) // Force line break + .catch((err) => { + const channelInfo = + "guild" in context && context.guild ? `${context.id} (${context.guild.id})` : context.id; + + logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`); + + return undefined; + }); + } + + const replyMethod = context.replied || context.deferred ? "editReply" : "reply"; + + return context[replyMethod]({ + content: formattedBody, + embeds: [], + components: [], + fetchReply: true, + ephemeral, + }).catch((err) => { + logger.error(`Context reply failed: ${err}`); + + return undefined; + }) as Promise; + }, + + sendErrorMessage: async ( + context: TextBasedChannel | Message | User | ChatInputCommandInteraction, + body: string, + allowedMentions?: MessageMentionOptions, + responseInteraction?: ModalSubmitInteraction, + ephemeral = true, + ): Promise => { + const emoji = getErrorEmoji(pluginData); + const formattedBody = errorMessage(body, emoji); + const content: MessageCreateOptions = allowedMentions + ? { content: formattedBody, allowedMentions } + : { content: formattedBody }; + + if (responseInteraction) { + await responseInteraction + .editReply({ content: formattedBody, embeds: [], components: [] }) + .catch((err) => logger.error(`Interaction reply failed: ${err}`)); + + return; + } + + if (!isContextInteraction(context)) { + // noinspection TypeScriptValidateJSTypes + return sendContextResponse(context, { ...content }) // Force line break + .catch((err) => { + const channelInfo = + "guild" in context && context.guild ? `${context.id} (${context.guild.id})` : context.id; + + logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`); + + return undefined; + }); + } + + const replyMethod = context.replied || context.deferred ? "editReply" : "reply"; + + return context[replyMethod]({ + content: formattedBody, + embeds: [], + components: [], + fetchReply: true, + ephemeral, + }).catch((err) => { + logger.error(`Context reply failed: ${err}`); + + return undefined; + }) as Promise; + }, + + storeAttachmentsAsMessage: async (attachments: Attachment[], backupChannel?: TextBasedChannel | null) => { + const attachmentChannelId = pluginData.config.get().attachment_storing_channel; + const channel = attachmentChannelId + ? (pluginData.guild.channels.cache.get(attachmentChannelId) as TextBasedChannel) ?? backupChannel + : backupChannel; + + if (!channel) { + throw new Error( + "Cannot store attachments: no attachment storing channel configured, and no backup channel passed", + ); + } + + return channel!.send({ + content: `Storing ${attachments.length} attachment${attachments.length === 1 ? "" : "s"}`, + files: attachments.map((a) => a.url), + }); + }, + }; + }, +}); diff --git a/backend/src/plugins/Common/docs.ts b/backend/src/plugins/Common/docs.ts new file mode 100644 index 00000000..1c65e392 --- /dev/null +++ b/backend/src/plugins/Common/docs.ts @@ -0,0 +1,9 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zCommonConfig } from "./types.js"; + +export const commonPluginDocs: ZeppelinPluginDocs = { + type: "internal", + configSchema: zCommonConfig, + + prettyName: "Common", +}; diff --git a/backend/src/plugins/Common/functions/getEmoji.ts b/backend/src/plugins/Common/functions/getEmoji.ts new file mode 100644 index 00000000..809d787b --- /dev/null +++ b/backend/src/plugins/Common/functions/getEmoji.ts @@ -0,0 +1,10 @@ +import { GuildPluginData } from "knub"; +import { CommonPluginType } from "../types.js"; + +export function getSuccessEmoji(pluginData: GuildPluginData) { + return pluginData.config.get().success_emoji ?? "✅"; +} + +export function getErrorEmoji(pluginData: GuildPluginData) { + return pluginData.config.get().error_emoji ?? "❌"; +} diff --git a/backend/src/plugins/Common/types.ts b/backend/src/plugins/Common/types.ts new file mode 100644 index 00000000..87a342f5 --- /dev/null +++ b/backend/src/plugins/Common/types.ts @@ -0,0 +1,12 @@ +import { BasePluginType } from "knub"; +import z from "zod"; + +export const zCommonConfig = z.strictObject({ + success_emoji: z.string(), + error_emoji: z.string(), + attachment_storing_channel: z.nullable(z.string()), +}); + +export interface CommonPluginType extends BasePluginType { + config: z.output; +} diff --git a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts index 330b409a..a8739785 100644 --- a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts +++ b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts @@ -1,30 +1,31 @@ import { PluginOptions, guildPlugin } from "knub"; -import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks.js"; +import { GuildCases } from "../../data/GuildCases.js"; +import { CasesPlugin } from "../Cases/CasesPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; +import { ModActionsPlugin } from "../ModActions/ModActionsPlugin.js"; import { MutesPlugin } from "../Mutes/MutesPlugin.js"; import { UtilityPlugin } from "../Utility/UtilityPlugin.js"; -import { ContextClickedEvt } from "./events/ContextClickedEvt.js"; +import { BanCmd } from "./commands/BanUserCtxCmd.js"; +import { CleanCmd } from "./commands/CleanMessageCtxCmd.js"; +import { ModMenuCmd } from "./commands/ModMenuUserCtxCmd.js"; +import { MuteCmd } from "./commands/MuteUserCtxCmd.js"; +import { NoteCmd } from "./commands/NoteUserCtxCmd.js"; +import { WarnCmd } from "./commands/WarnUserCtxCmd.js"; import { ContextMenuPluginType, zContextMenusConfig } from "./types.js"; -import { loadAllCommands } from "./utils/loadAllCommands.js"; const defaultOptions: PluginOptions = { config: { can_use: false, - user_muteindef: false, - user_mute1d: false, - user_mute1h: false, - user_info: false, - - message_clean10: false, - message_clean25: false, - message_clean50: false, + can_open_mod_menu: false, }, overrides: [ { level: ">=50", config: { can_use: true, + + can_open_mod_menu: true, }, }, ], @@ -33,22 +34,15 @@ const defaultOptions: PluginOptions = { export const ContextMenuPlugin = guildPlugin()({ name: "context_menu", - dependencies: () => [MutesPlugin, LogsPlugin, UtilityPlugin], + dependencies: () => [CasesPlugin, MutesPlugin, ModActionsPlugin, LogsPlugin, UtilityPlugin], configParser: (input) => zContextMenusConfig.parse(input), defaultOptions, - // prettier-ignore - events: [ - ContextClickedEvt, - ], + contextMenuCommands: [ModMenuCmd, NoteCmd, WarnCmd, MuteCmd, BanCmd, CleanCmd], beforeLoad(pluginData) { const { state, guild } = pluginData; - state.contextMenuLinks = new GuildContextMenuLinks(guild.id); - }, - - afterLoad(pluginData) { - loadAllCommands(pluginData); + state.cases = GuildCases.getGuildInstance(guild.id); }, }); diff --git a/backend/src/plugins/ContextMenus/actions/ban.ts b/backend/src/plugins/ContextMenus/actions/ban.ts new file mode 100644 index 00000000..644a293b --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/ban.ts @@ -0,0 +1,116 @@ +import { + ActionRowBuilder, + ButtonInteraction, + ContextMenuCommandInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import humanizeDuration from "humanize-duration"; +import { GuildPluginData } from "knub"; +import { logger } from "../../../logger.js"; +import { canActOn } from "../../../pluginUtils.js"; +import { convertDelayStringToMS, renderUserUsername } from "../../../utils.js"; +import { CaseArgs } from "../../Cases/types.js"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; +import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; +import { updateAction } from "./update.js"; + +async function banAction( + pluginData: GuildPluginData, + duration: string | undefined, + reason: string | undefined, + evidence: string | undefined, + target: string, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + submitInteraction: ModalSubmitInteraction, +) { + const interactionToReply = interaction.isButton() ? interaction : submitInteraction; + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + + const modactions = pluginData.getPlugin(ModActionsPlugin); + if (!userCfg.can_use || !(await modactions.hasBanPermission(executingMember, interaction.channelId))) { + await interactionToReply.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); + return; + } + + const targetMember = await pluginData.guild.members.fetch(target); + if (!canActOn(pluginData, executingMember, targetMember)) { + await interactionToReply.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); + return; + } + + const caseArgs: Partial = { + modId: executingMember.id, + }; + + const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; + const result = await modactions.banUserId(target, reason, reason, { caseArgs }, durationMs); + if (result.status === "failed") { + await interactionToReply.editReply({ content: "Error: Failed to ban user", embeds: [], components: [] }); + return; + } + + const userName = renderUserUsername(targetMember.user); + const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; + const banMessage = `Banned **${userName}** ${ + durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" + } (Case #${result.case.case_number})${messageResultText}`; + + if (evidence) { + await updateAction(pluginData, executingMember, result.case, evidence); + } + + await interactionToReply.editReply({ content: banMessage, embeds: [], components: [] }); +} + +export async function launchBanActionModal( + pluginData: GuildPluginData, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + target: string, +) { + const modalId = `${ModMenuActionType.BAN}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Ban"); + const durationIn = new TextInputBuilder() + .setCustomId("duration") + .setLabel("Duration (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Short); + const reasonIn = new TextInputBuilder() + .setCustomId("reason") + .setLabel("Reason (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); + const evidenceIn = new TextInputBuilder() + .setCustomId("evidence") + .setLabel("Evidence (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); + const durationRow = new ActionRowBuilder().addComponents(durationIn); + const reasonRow = new ActionRowBuilder().addComponents(reasonIn); + const evidenceRow = new ActionRowBuilder().addComponents(evidenceIn); + modal.addComponents(durationRow, reasonRow, evidenceRow); + + await interaction.showModal(modal); + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + if (interaction.isButton()) { + await submitted.deferUpdate().catch((err) => logger.error(`Ban interaction defer failed: ${err}`)); + } else if (interaction.isContextMenuCommand()) { + await submitted.deferReply({ ephemeral: true }); + } + + const duration = submitted.fields.getTextInputValue("duration"); + const reason = submitted.fields.getTextInputValue("reason"); + const evidence = submitted.fields.getTextInputValue("evidence"); + + await banAction(pluginData, duration, reason, evidence, target, interaction, submitted); + }); +} diff --git a/backend/src/plugins/ContextMenus/actions/clean.ts b/backend/src/plugins/ContextMenus/actions/clean.ts index 9278f04b..da29018a 100644 --- a/backend/src/plugins/ContextMenus/actions/clean.ts +++ b/backend/src/plugins/ContextMenus/actions/clean.ts @@ -1,16 +1,26 @@ -import { ContextMenuCommandInteraction, TextChannel } from "discord.js"; +import { + ActionRowBuilder, + Message, + MessageContextMenuCommandInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; import { GuildPluginData } from "knub"; -import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; +import { logger } from "../../../logger.js"; import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { ContextMenuPluginType } from "../types.js"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; +import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; export async function cleanAction( pluginData: GuildPluginData, amount: number, - interaction: ContextMenuCommandInteraction, + target: string, + targetMessage: Message, + targetChannel: string, + interaction: ModalSubmitInteraction, ) { - await interaction.deferReply({ ephemeral: true }); const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -18,33 +28,54 @@ export async function cleanAction( }); const utility = pluginData.getPlugin(UtilityPlugin); - if (!userCfg.can_use || !(await utility.hasPermission(executingMember, interaction.channelId, "can_clean"))) { - await interaction.followUp({ content: "Cannot clean: insufficient permissions" }); + if (!userCfg.can_use || !(await utility.hasPermission(executingMember, targetChannel, "can_clean"))) { + await interaction + .editReply({ content: "Cannot clean: insufficient permissions", embeds: [], components: [] }) + .catch((err) => logger.error(`Clean interaction reply failed: ${err}`)); return; } - const targetMessage = interaction.channel - ? await interaction.channel.messages.fetch(interaction.targetId) - : await (pluginData.guild.channels.resolve(interaction.channelId) as TextChannel).messages.fetch( - interaction.targetId, - ); + await interaction + .editReply({ + content: `Cleaning ${amount} messages from ${target}...`, + embeds: [], + components: [], + }) + .catch((err) => logger.error(`Clean interaction reply failed: ${err}`)); - const targetUserOnly = false; - const deletePins = false; - const user = undefined; - - try { - await interaction.followUp(`Cleaning... Amount: ${amount}, User Only: ${targetUserOnly}, Pins: ${deletePins}`); - utility.clean({ count: amount, user, channel: targetMessage.channel.id, "delete-pins": deletePins }, targetMessage); - } catch (e) { - await interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" }); - - if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { - pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Failed to clean in <#${interaction.channelId}> in ContextMenu action \`clean\`:_ ${e}`, - }); - } else { - throw e; - } - } + await utility.clean({ count: amount, channel: targetChannel, "response-interaction": interaction }, targetMessage); +} + +export async function launchCleanActionModal( + pluginData: GuildPluginData, + interaction: MessageContextMenuCommandInteraction, + target: string, +) { + const modalId = `${ModMenuActionType.CLEAN}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Clean"); + const amountIn = new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short); + const amountRow = new ActionRowBuilder().addComponents(amountIn); + modal.addComponents(amountRow); + + await interaction.showModal(modal); + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + await submitted.deferReply({ ephemeral: true }); + + const amount = submitted.fields.getTextInputValue("amount"); + if (isNaN(Number(amount))) { + interaction.editReply({ content: `Error: Amount '${amount}' is invalid`, embeds: [], components: [] }); + return; + } + + await cleanAction( + pluginData, + Number(amount), + target, + interaction.targetMessage, + interaction.channelId, + submitted, + ); + }); } diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts index d7dc6052..380f5e3a 100644 --- a/backend/src/plugins/ContextMenus/actions/mute.ts +++ b/backend/src/plugins/ContextMenus/actions/mute.ts @@ -1,21 +1,36 @@ -import { ContextMenuCommandInteraction } from "discord.js"; +import { + ActionRowBuilder, + ButtonInteraction, + ContextMenuCommandInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; +import { logger } from "../../../logger.js"; import { canActOn } from "../../../pluginUtils.js"; import { convertDelayStringToMS } from "../../../utils.js"; import { CaseArgs } from "../../Cases/types.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; import { MutesPlugin } from "../../Mutes/MutesPlugin.js"; -import { ContextMenuPluginType } from "../types.js"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; +import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; +import { updateAction } from "./update.js"; -export async function muteAction( +async function muteAction( pluginData: GuildPluginData, duration: string | undefined, - interaction: ContextMenuCommandInteraction, + reason: string | undefined, + evidence: string | undefined, + target: string, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + submitInteraction: ModalSubmitInteraction, ) { - await interaction.deferReply({ ephemeral: true }); + const interactionToReply = interaction.isButton() ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -24,43 +39,100 @@ export async function muteAction( const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasMutePermission(executingMember, interaction.channelId))) { - await interaction.followUp({ content: "Cannot mute: insufficient permissions" }); + await interactionToReply.editReply({ + content: "Cannot mute: insufficient permissions", + embeds: [], + components: [], + }); return; } - const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; - const mutes = pluginData.getPlugin(MutesPlugin); - const userId = interaction.targetId; - const targetMember = await pluginData.guild.members.fetch(interaction.targetId); - + const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { - await interaction.followUp({ ephemeral: true, content: "Cannot mute: insufficient permissions" }); + await interactionToReply.editReply({ + content: "Cannot mute: insufficient permissions", + embeds: [], + components: [], + }); return; } const caseArgs: Partial = { modId: executingMember.id, }; + const mutes = pluginData.getPlugin(MutesPlugin); + const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; try { - const result = await mutes.muteUser(userId, durationMs, "Context Menu Action", { caseArgs }); - + const result = await mutes.muteUser(target, durationMs, reason, reason, { caseArgs }); + const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; const muteMessage = `Muted **${result.case!.user_name}** ${ durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" - } (Case #${result.case!.case_number}) (user notified via ${ - result.notifyResult.method ?? "dm" - })\nPlease update the new case with the \`update\` command`; + } (Case #${result.case!.case_number})${messageResultText}`; - await interaction.followUp({ ephemeral: true, content: muteMessage }); + if (evidence) { + await updateAction(pluginData, executingMember, result.case!, evidence); + } + + await interactionToReply.editReply({ content: muteMessage, embeds: [], components: [] }); } catch (e) { - await interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" }); + await interactionToReply.editReply({ + content: "Plugin error, please check your BOT_ALERTs", + embeds: [], + components: [], + }); if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Failed to mute <@!${userId}> in ContextMenu action \`mute\` because a mute role has not been specified in server config`, + body: `Failed to mute <@!${target}> in ContextMenu action \`mute\` because a mute role has not been specified in server config`, }); } else { throw e; } } } + +export async function launchMuteActionModal( + pluginData: GuildPluginData, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + target: string, +) { + const modalId = `${ModMenuActionType.MUTE}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Mute"); + const durationIn = new TextInputBuilder() + .setCustomId("duration") + .setLabel("Duration (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Short); + const reasonIn = new TextInputBuilder() + .setCustomId("reason") + .setLabel("Reason (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); + const evidenceIn = new TextInputBuilder() + .setCustomId("evidence") + .setLabel("Evidence (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); + const durationRow = new ActionRowBuilder().addComponents(durationIn); + const reasonRow = new ActionRowBuilder().addComponents(reasonIn); + const evidenceRow = new ActionRowBuilder().addComponents(evidenceIn); + modal.addComponents(durationRow, reasonRow, evidenceRow); + + await interaction.showModal(modal); + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + if (interaction.isButton()) { + await submitted.deferUpdate().catch((err) => logger.error(`Mute interaction defer failed: ${err}`)); + } else if (interaction.isContextMenuCommand()) { + await submitted.deferReply({ ephemeral: true }); + } + + const duration = submitted.fields.getTextInputValue("duration"); + const reason = submitted.fields.getTextInputValue("reason"); + const evidence = submitted.fields.getTextInputValue("evidence"); + + await muteAction(pluginData, duration, reason, evidence, target, interaction, submitted); + }); +} diff --git a/backend/src/plugins/ContextMenus/actions/note.ts b/backend/src/plugins/ContextMenus/actions/note.ts new file mode 100644 index 00000000..566d44ad --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/note.ts @@ -0,0 +1,103 @@ +import { + ActionRowBuilder, + ButtonInteraction, + ContextMenuCommandInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../data/CaseTypes.js"; +import { logger } from "../../../logger.js"; +import { canActOn } from "../../../pluginUtils.js"; +import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; +import { renderUserUsername } from "../../../utils.js"; +import { LogsPlugin } from "../../Logs/LogsPlugin.js"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; +import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; + +async function noteAction( + pluginData: GuildPluginData, + reason: string, + target: string, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + submitInteraction: ModalSubmitInteraction, +) { + const interactionToReply = interaction.isButton() ? interaction : submitInteraction; + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + + const modactions = pluginData.getPlugin(ModActionsPlugin); + if (!userCfg.can_use || !(await modactions.hasNotePermission(executingMember, interaction.channelId))) { + await interactionToReply.editReply({ + content: "Cannot note: insufficient permissions", + embeds: [], + components: [], + }); + return; + } + + const targetMember = await pluginData.guild.members.fetch(target); + if (!canActOn(pluginData, executingMember, targetMember)) { + await interactionToReply.editReply({ + content: "Cannot note: insufficient permissions", + embeds: [], + components: [], + }); + return; + } + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: target, + modId: executingMember.id, + type: CaseTypes.Note, + reason, + }); + + pluginData.getPlugin(LogsPlugin).logMemberNote({ + mod: interaction.user, + user: targetMember.user, + caseNumber: createdCase.case_number, + reason, + }); + + const userName = renderUserUsername(targetMember.user); + await interactionToReply.editReply({ + content: `Note added on **${userName}** (Case #${createdCase.case_number})`, + embeds: [], + components: [], + }); +} + +export async function launchNoteActionModal( + pluginData: GuildPluginData, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + target: string, +) { + const modalId = `${ModMenuActionType.NOTE}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Note"); + const reasonIn = new TextInputBuilder().setCustomId("reason").setLabel("Note").setStyle(TextInputStyle.Paragraph); + const reasonRow = new ActionRowBuilder().addComponents(reasonIn); + modal.addComponents(reasonRow); + + await interaction.showModal(modal); + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + if (interaction.isButton()) { + await submitted.deferUpdate().catch((err) => logger.error(`Note interaction defer failed: ${err}`)); + } else if (interaction.isContextMenuCommand()) { + await submitted.deferReply({ ephemeral: true }); + } + + const reason = submitted.fields.getTextInputValue("reason"); + + await noteAction(pluginData, reason, target, interaction, submitted); + }); +} diff --git a/backend/src/plugins/ContextMenus/actions/update.ts b/backend/src/plugins/ContextMenus/actions/update.ts new file mode 100644 index 00000000..841b406b --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/update.ts @@ -0,0 +1,28 @@ +import { GuildMember } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../data/CaseTypes.js"; +import { Case } from "../../../data/entities/Case.js"; +import { CasesPlugin } from "../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../Logs/LogsPlugin.js"; +import { ContextMenuPluginType } from "../types.js"; + +export async function updateAction( + pluginData: GuildPluginData, + executingMember: GuildMember, + theCase: Case, + value: string, +) { + const casesPlugin = pluginData.getPlugin(CasesPlugin); + await casesPlugin.createCaseNote({ + caseId: theCase.case_number, + modId: executingMember.id, + body: value, + }); + + void pluginData.getPlugin(LogsPlugin).logCaseUpdate({ + mod: executingMember.user, + caseNumber: theCase.case_number, + caseType: CaseTypes[theCase.type], + note: value, + }); +} diff --git a/backend/src/plugins/ContextMenus/actions/userInfo.ts b/backend/src/plugins/ContextMenus/actions/userInfo.ts deleted file mode 100644 index cb47016c..00000000 --- a/backend/src/plugins/ContextMenus/actions/userInfo.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ContextMenuCommandInteraction } from "discord.js"; -import { GuildPluginData } from "knub"; -import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin.js"; -import { ContextMenuPluginType } from "../types.js"; - -export async function userInfoAction( - pluginData: GuildPluginData, - interaction: ContextMenuCommandInteraction, -) { - await interaction.deferReply({ ephemeral: true }); - const executingMember = await pluginData.guild.members.fetch(interaction.user.id); - const userCfg = await pluginData.config.getMatchingConfig({ - channelId: interaction.channelId, - member: executingMember, - }); - const utility = pluginData.getPlugin(UtilityPlugin); - - if (userCfg.can_use && (await utility.hasPermission(executingMember, interaction.channelId, "can_userinfo"))) { - const embed = await utility.userInfo(interaction.targetId); - if (!embed) { - await interaction.followUp({ content: "Cannot info: internal error" }); - return; - } - await interaction.followUp({ embeds: [embed] }); - } else { - await interaction.followUp({ content: "Cannot info: insufficient permissions" }); - } -} diff --git a/backend/src/plugins/ContextMenus/actions/warn.ts b/backend/src/plugins/ContextMenus/actions/warn.ts new file mode 100644 index 00000000..e0e34707 --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/warn.ts @@ -0,0 +1,108 @@ +import { + ActionRowBuilder, + ButtonInteraction, + ContextMenuCommandInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { GuildPluginData } from "knub"; +import { logger } from "../../../logger.js"; +import { canActOn } from "../../../pluginUtils.js"; +import { renderUserUsername } from "../../../utils.js"; +import { CaseArgs } from "../../Cases/types.js"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; +import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; +import { updateAction } from "./update.js"; + +async function warnAction( + pluginData: GuildPluginData, + reason: string, + evidence: string | undefined, + target: string, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + submitInteraction: ModalSubmitInteraction, +) { + const interactionToReply = interaction.isButton() ? interaction : submitInteraction; + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + + const modactions = pluginData.getPlugin(ModActionsPlugin); + if (!userCfg.can_use || !(await modactions.hasWarnPermission(executingMember, interaction.channelId))) { + await interactionToReply.editReply({ + content: "Cannot warn: insufficient permissions", + embeds: [], + components: [], + }); + return; + } + + const targetMember = await pluginData.guild.members.fetch(target); + if (!canActOn(pluginData, executingMember, targetMember)) { + await interactionToReply.editReply({ + content: "Cannot warn: insufficient permissions", + embeds: [], + components: [], + }); + return; + } + + const caseArgs: Partial = { + modId: executingMember.id, + }; + + const result = await modactions.warnMember(targetMember, reason, reason, { caseArgs }); + if (result.status === "failed") { + await interactionToReply.editReply({ content: "Error: Failed to warn user", embeds: [], components: [] }); + return; + } + + const userName = renderUserUsername(targetMember.user); + const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; + const muteMessage = `Warned **${userName}** (Case #${result.case.case_number})${messageResultText}`; + + if (evidence) { + await updateAction(pluginData, executingMember, result.case, evidence); + } + + await interactionToReply.editReply({ content: muteMessage, embeds: [], components: [] }); +} + +export async function launchWarnActionModal( + pluginData: GuildPluginData, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + target: string, +) { + const modalId = `${ModMenuActionType.WARN}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Warn"); + const reasonIn = new TextInputBuilder().setCustomId("reason").setLabel("Reason").setStyle(TextInputStyle.Paragraph); + const evidenceIn = new TextInputBuilder() + .setCustomId("evidence") + .setLabel("Evidence (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); + const reasonRow = new ActionRowBuilder().addComponents(reasonIn); + const evidenceRow = new ActionRowBuilder().addComponents(evidenceIn); + modal.addComponents(reasonRow, evidenceRow); + + await interaction.showModal(modal); + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + if (interaction.isButton()) { + await submitted.deferUpdate().catch((err) => logger.error(`Warn interaction defer failed: ${err}`)); + } else if (interaction.isContextMenuCommand()) { + await submitted.deferReply({ ephemeral: true }); + } + + const reason = submitted.fields.getTextInputValue("reason"); + const evidence = submitted.fields.getTextInputValue("evidence"); + + await warnAction(pluginData, reason, evidence, target, interaction, submitted); + }); +} diff --git a/backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts new file mode 100644 index 00000000..dfff9088 --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts @@ -0,0 +1,11 @@ +import { PermissionFlagsBits } from "discord.js"; +import { guildPluginUserContextMenuCommand } from "knub"; +import { launchBanActionModal } from "../actions/ban.js"; + +export const BanCmd = guildPluginUserContextMenuCommand({ + name: "Ban", + defaultMemberPermissions: PermissionFlagsBits.BanMembers.toString(), + async run({ pluginData, interaction }) { + await launchBanActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/ContextMenus/commands/CleanMessageCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/CleanMessageCtxCmd.ts new file mode 100644 index 00000000..83508ae3 --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/CleanMessageCtxCmd.ts @@ -0,0 +1,11 @@ +import { PermissionFlagsBits } from "discord.js"; +import { guildPluginMessageContextMenuCommand } from "knub"; +import { launchCleanActionModal } from "../actions/clean.js"; + +export const CleanCmd = guildPluginMessageContextMenuCommand({ + name: "Clean", + defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(), + async run({ pluginData, interaction }) { + await launchCleanActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts new file mode 100644 index 00000000..e3091ca8 --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts @@ -0,0 +1,328 @@ +import { + APIEmbed, + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ContextMenuCommandInteraction, + GuildMember, + PermissionFlagsBits, + User, +} from "discord.js"; +import { GuildPluginData, guildPluginUserContextMenuCommand } from "knub"; +import { Case } from "../../../data/entities/Case.js"; +import { logger } from "../../../logger.js"; +import { SECONDS, UnknownUser, emptyEmbedValue, renderUserUsername, resolveUser, trimLines } from "../../../utils.js"; +import { asyncMap } from "../../../utils/async.js"; +import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields.js"; +import { getGuildPrefix } from "../../../utils/getGuildPrefix.js"; +import { CasesPlugin } from "../../Cases/CasesPlugin.js"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; +import { getUserInfoEmbed } from "../../Utility/functions/getUserInfoEmbed.js"; +import { launchBanActionModal } from "../actions/ban.js"; +import { launchMuteActionModal } from "../actions/mute.js"; +import { launchNoteActionModal } from "../actions/note.js"; +import { launchWarnActionModal } from "../actions/warn.js"; +import { + ContextMenuPluginType, + LoadModMenuPageFn, + ModMenuActionOpts, + ModMenuActionType, + ModMenuNavigationType, +} from "../types.js"; + +export const MODAL_TIMEOUT = 60 * SECONDS; +const MOD_MENU_TIMEOUT = 60 * SECONDS; +const CASES_PER_PAGE = 10; + +export const ModMenuCmd = guildPluginUserContextMenuCommand({ + name: "Mod Menu", + defaultMemberPermissions: PermissionFlagsBits.ViewAuditLog.toString(), + async run({ pluginData, interaction }) { + await interaction.deferReply({ ephemeral: true }); + + // Run permission checks for executing user. + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + if (!userCfg.can_use || !userCfg.can_open_mod_menu) { + await interaction.followUp({ content: "Error: Insufficient Permissions" }); + return; + } + + const user = await resolveUser(pluginData.client, interaction.targetId); + if (!user.id) { + await interaction.followUp("Error: User not found"); + return; + } + + // Load cases and display mod menu + const cases: Case[] = await pluginData.state.cases.with("notes").getByUserId(user.id); + const userName = + user instanceof UnknownUser && cases.length ? cases[cases.length - 1].user_name : renderUserUsername(user); + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const totalCases = cases.length; + const totalPages: number = Math.max(Math.ceil(totalCases / CASES_PER_PAGE), 1); + const prefix = getGuildPrefix(pluginData); + const infoEmbed = await getUserInfoEmbed(pluginData, user.id, false); + displayModMenu( + pluginData, + interaction, + totalPages, + async (page) => { + const pageCases: Case[] = await pluginData.state.cases + .with("notes") + .getRecentByUserId(user.id, CASES_PER_PAGE, (page - 1) * CASES_PER_PAGE); + const lines = await asyncMap(pageCases, (c) => casesPlugin.getCaseSummary(c, true, interaction.targetId)); + + const firstCaseNum = (page - 1) * CASES_PER_PAGE + 1; + const lastCaseNum = Math.min(page * CASES_PER_PAGE, totalCases); + const title = + lines.length == 0 + ? `${userName}` + : `Most recent cases for ${userName} | ${firstCaseNum}-${lastCaseNum} of ${totalCases}`; + + const embed = { + author: { + name: title, + icon_url: user instanceof User ? user.displayAvatarURL() : undefined, + }, + fields: [ + ...getChunkedEmbedFields( + emptyEmbedValue, + lines.length == 0 ? `No cases found for **${userName}**` : lines.join("\n"), + ), + { + name: emptyEmbedValue, + value: trimLines( + lines.length == 0 ? "" : `Use \`${prefix}case \` to see more information about an individual case`, + ), + }, + ], + footer: { text: `Page ${page}/${totalPages}` }, + } satisfies APIEmbed; + + return embed; + }, + infoEmbed, + executingMember, + ); + }, +}); + +async function displayModMenu( + pluginData: GuildPluginData, + interaction: ContextMenuCommandInteraction, + totalPages: number, + loadPage: LoadModMenuPageFn, + infoEmbed: APIEmbed | null, + executingMember: GuildMember, +) { + if (interaction.deferred == false) { + await interaction.deferReply().catch((err) => logger.error(`Mod menu interaction defer failed: ${err}`)); + } + + const firstButton = new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setEmoji("⏪") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.FIRST })) + .setDisabled(true); + const prevButton = new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setEmoji("⬅") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.PREV })) + .setDisabled(true); + const infoButton = new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("Info") + .setEmoji("ℹ") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO })) + .setDisabled(infoEmbed != null ? false : true); + const nextButton = new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setEmoji("➡") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.NEXT })) + .setDisabled(totalPages > 1 ? false : true); + const lastButton = new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setEmoji("⏩") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.LAST })) + .setDisabled(totalPages > 1 ? false : true); + const navigationButtons = [firstButton, prevButton, infoButton, nextButton, lastButton] satisfies ButtonBuilder[]; + + const modactions = pluginData.getPlugin(ModActionsPlugin); + const moderationButtons = [ + new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("Note") + .setEmoji("📝") + .setDisabled(!(await modactions.hasNotePermission(executingMember, interaction.channelId))) + .setCustomId(serializeCustomId({ action: ModMenuActionType.NOTE, target: interaction.targetId })), + new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("Warn") + .setEmoji("⚠️") + .setDisabled(!(await modactions.hasWarnPermission(executingMember, interaction.channelId))) + .setCustomId(serializeCustomId({ action: ModMenuActionType.WARN, target: interaction.targetId })), + new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("Mute") + .setEmoji("🔇") + .setDisabled(!(await modactions.hasMutePermission(executingMember, interaction.channelId))) + .setCustomId(serializeCustomId({ action: ModMenuActionType.MUTE, target: interaction.targetId })), + new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("Ban") + .setEmoji("🚫") + .setDisabled(!(await modactions.hasBanPermission(executingMember, interaction.channelId))) + .setCustomId(serializeCustomId({ action: ModMenuActionType.BAN, target: interaction.targetId })), + ] satisfies ButtonBuilder[]; + + const navigationRow = new ActionRowBuilder().addComponents(navigationButtons); + const moderationRow = new ActionRowBuilder().addComponents(moderationButtons); + + let page = 1; + await interaction + .editReply({ + embeds: [await loadPage(page)], + components: [navigationRow, moderationRow], + }) + .then(async (currentPage) => { + const collector = await currentPage.createMessageComponentCollector({ + time: MOD_MENU_TIMEOUT, + }); + + collector.on("collect", async (i) => { + const opts = deserializeCustomId(i.customId); + if (opts.action == ModMenuActionType.PAGE) { + await i.deferUpdate().catch((err) => logger.error(`Mod menu defer failed: ${err}`)); + } + + // Update displayed embed if any navigation buttons were used + if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.INFO && infoEmbed != null) { + infoButton + .setLabel("Cases") + .setEmoji("📋") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.CASES })); + firstButton.setDisabled(true); + prevButton.setDisabled(true); + nextButton.setDisabled(true); + lastButton.setDisabled(true); + + await i + .editReply({ + embeds: [infoEmbed], + components: [navigationRow, moderationRow], + }) + .catch((err) => logger.error(`Mod menu info view failed: ${err}`)); + } else if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.CASES) { + infoButton + .setLabel("Info") + .setEmoji("ℹ") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO })); + updateNavButtonState(firstButton, prevButton, nextButton, lastButton, page, totalPages); + + await i + .editReply({ + embeds: [await loadPage(page)], + components: [navigationRow, moderationRow], + }) + .catch((err) => logger.error(`Mod menu cases view failed: ${err}`)); + } else if (opts.action == ModMenuActionType.PAGE) { + let pageDelta = 0; + switch (opts.target) { + case ModMenuNavigationType.PREV: + pageDelta = -1; + break; + case ModMenuNavigationType.NEXT: + pageDelta = 1; + break; + } + + let newPage = 1; + if (opts.target == ModMenuNavigationType.PREV || opts.target == ModMenuNavigationType.NEXT) { + newPage = Math.max(Math.min(page + pageDelta, totalPages), 1); + } else if (opts.target == ModMenuNavigationType.FIRST) { + newPage = 1; + } else if (opts.target == ModMenuNavigationType.LAST) { + newPage = totalPages; + } + + if (newPage != page) { + updateNavButtonState(firstButton, prevButton, nextButton, lastButton, newPage, totalPages); + + await i + .editReply({ + embeds: [await loadPage(newPage)], + components: [navigationRow, moderationRow], + }) + .catch((err) => logger.error(`Mod menu navigation failed: ${err}`)); + + page = newPage; + } + } else if (opts.action == ModMenuActionType.NOTE) { + await launchNoteActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.WARN) { + await launchWarnActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.MUTE) { + await launchMuteActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.BAN) { + await launchBanActionModal(pluginData, i as ButtonInteraction, opts.target); + } + + collector.resetTimer(); + }); + + // Remove components on timeout. + collector.on("end", async (_, reason) => { + if (reason !== "messageDelete") { + await interaction + .editReply({ + components: [], + }) + .catch((err) => logger.error(`Mod menu timeout failed: ${err}`)); + } + }); + }) + .catch((err) => logger.error(`Mod menu setup failed: ${err}`)); +} + +function serializeCustomId(opts: ModMenuActionOpts) { + return `${opts.action}:${opts.target}`; +} + +function deserializeCustomId(customId: string): ModMenuActionOpts { + const opts: ModMenuActionOpts = { + action: customId.split(":")[0] as ModMenuActionType, + target: customId.split(":")[1], + }; + + return opts; +} + +function updateNavButtonState( + firstButton: ButtonBuilder, + prevButton: ButtonBuilder, + nextButton: ButtonBuilder, + lastButton: ButtonBuilder, + currentPage: number, + totalPages: number, +) { + if (currentPage > 1) { + firstButton.setDisabled(false); + prevButton.setDisabled(false); + } else { + firstButton.setDisabled(true); + prevButton.setDisabled(true); + } + + if (currentPage == totalPages) { + nextButton.setDisabled(true); + lastButton.setDisabled(true); + } else { + nextButton.setDisabled(false); + lastButton.setDisabled(false); + } +} diff --git a/backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts new file mode 100644 index 00000000..559f6e70 --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts @@ -0,0 +1,11 @@ +import { PermissionFlagsBits } from "discord.js"; +import { guildPluginUserContextMenuCommand } from "knub"; +import { launchMuteActionModal } from "../actions/mute.js"; + +export const MuteCmd = guildPluginUserContextMenuCommand({ + name: "Mute", + defaultMemberPermissions: PermissionFlagsBits.ModerateMembers.toString(), + async run({ pluginData, interaction }) { + await launchMuteActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts new file mode 100644 index 00000000..0e3807f3 --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts @@ -0,0 +1,11 @@ +import { PermissionFlagsBits } from "discord.js"; +import { guildPluginUserContextMenuCommand } from "knub"; +import { launchNoteActionModal } from "../actions/note.js"; + +export const NoteCmd = guildPluginUserContextMenuCommand({ + name: "Note", + defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(), + async run({ pluginData, interaction }) { + await launchNoteActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts new file mode 100644 index 00000000..4721544a --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts @@ -0,0 +1,11 @@ +import { PermissionFlagsBits } from "discord.js"; +import { guildPluginUserContextMenuCommand } from "knub"; +import { launchWarnActionModal } from "../actions/warn.js"; + +export const WarnCmd = guildPluginUserContextMenuCommand({ + name: "Warn", + defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(), + async run({ pluginData, interaction }) { + await launchWarnActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts b/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts deleted file mode 100644 index dcfdaab0..00000000 --- a/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { contextMenuEvt } from "../types.js"; -import { routeContextAction } from "../utils/contextRouter.js"; - -export const ContextClickedEvt = contextMenuEvt({ - event: "interactionCreate", - - async listener(meta) { - if (!meta.args.interaction.isContextMenuCommand()) return; - const inter = meta.args.interaction; - await routeContextAction(meta.pluginData, inter); - }, -}); diff --git a/backend/src/plugins/ContextMenus/types.ts b/backend/src/plugins/ContextMenus/types.ts index f6ff126a..69276b52 100644 --- a/backend/src/plugins/ContextMenus/types.ts +++ b/backend/src/plugins/ContextMenus/types.ts @@ -1,23 +1,41 @@ -import { BasePluginType, guildPluginEventListener } from "knub"; +import { APIEmbed, Awaitable } from "discord.js"; +import { BasePluginType } from "knub"; import z from "zod"; -import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks.js"; +import { GuildCases } from "../../data/GuildCases.js"; export const zContextMenusConfig = z.strictObject({ can_use: z.boolean(), - user_muteindef: z.boolean(), - user_mute1d: z.boolean(), - user_mute1h: z.boolean(), - user_info: z.boolean(), - message_clean10: z.boolean(), - message_clean25: z.boolean(), - message_clean50: z.boolean(), + can_open_mod_menu: z.boolean(), }); export interface ContextMenuPluginType extends BasePluginType { config: z.infer; state: { - contextMenuLinks: GuildContextMenuLinks; + cases: GuildCases; }; } -export const contextMenuEvt = guildPluginEventListener(); +export const enum ModMenuActionType { + PAGE = "page", + NOTE = "note", + WARN = "warn", + CLEAN = "clean", + MUTE = "mute", + BAN = "ban", +} + +export const enum ModMenuNavigationType { + FIRST = "first", + PREV = "prev", + NEXT = "next", + LAST = "last", + INFO = "info", + CASES = "cases", +} + +export interface ModMenuActionOpts { + action: ModMenuActionType; + target: string; +} + +export type LoadModMenuPageFn = (page: number) => Awaitable; diff --git a/backend/src/plugins/ContextMenus/utils/contextRouter.ts b/backend/src/plugins/ContextMenus/utils/contextRouter.ts deleted file mode 100644 index 6a868479..00000000 --- a/backend/src/plugins/ContextMenus/utils/contextRouter.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ContextMenuCommandInteraction } from "discord.js"; -import { GuildPluginData } from "knub"; -import { ContextMenuPluginType } from "../types.js"; -import { hardcodedActions } from "./hardcodedContextOptions.js"; - -export async function routeContextAction( - pluginData: GuildPluginData, - interaction: ContextMenuCommandInteraction, -) { - const contextLink = await pluginData.state.contextMenuLinks.get(interaction.commandId); - if (!contextLink) return; - hardcodedActions[contextLink.action_name](pluginData, interaction); -} diff --git a/backend/src/plugins/ContextMenus/utils/hardcodedContextOptions.ts b/backend/src/plugins/ContextMenus/utils/hardcodedContextOptions.ts deleted file mode 100644 index 81dd48f8..00000000 --- a/backend/src/plugins/ContextMenus/utils/hardcodedContextOptions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { cleanAction } from "../actions/clean.js"; -import { muteAction } from "../actions/mute.js"; -import { userInfoAction } from "../actions/userInfo.js"; - -export const hardcodedContext: Record = { - user_muteindef: "Mute Indefinitely", - user_mute1d: "Mute for 1 day", - user_mute1h: "Mute for 1 hour", - user_info: "Get Info", - message_clean10: "Clean 10 messages", - message_clean25: "Clean 25 messages", - message_clean50: "Clean 50 messages", -}; - -export const hardcodedActions = { - user_muteindef: (pluginData, interaction) => muteAction(pluginData, undefined, interaction), - user_mute1d: (pluginData, interaction) => muteAction(pluginData, "1d", interaction), - user_mute1h: (pluginData, interaction) => muteAction(pluginData, "1h", interaction), - user_info: (pluginData, interaction) => userInfoAction(pluginData, interaction), - message_clean10: (pluginData, interaction) => cleanAction(pluginData, 10, interaction), - message_clean25: (pluginData, interaction) => cleanAction(pluginData, 25, interaction), - message_clean50: (pluginData, interaction) => cleanAction(pluginData, 50, interaction), -}; diff --git a/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts b/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts deleted file mode 100644 index e2c146d5..00000000 --- a/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ApplicationCommandData, ApplicationCommandType } from "discord.js"; -import { GuildPluginData } from "knub"; -import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin.js"; -import { ContextMenuPluginType } from "../types.js"; -import { hardcodedContext } from "./hardcodedContextOptions.js"; - -export async function loadAllCommands(pluginData: GuildPluginData) { - const comms = await pluginData.client.application!.commands; - const cfg = pluginData.config.get(); - const newCommands: ApplicationCommandData[] = []; - const addedNames: string[] = []; - - for (const [name, label] of Object.entries(hardcodedContext)) { - if (!cfg[name]) continue; - - const type = name.startsWith("user") ? ApplicationCommandType.User : ApplicationCommandType.Message; - const data: ApplicationCommandData = { - type, - name: label, - }; - - addedNames.push(name); - newCommands.push(data); - } - - const setCommands = await comms.set(newCommands, pluginData.guild.id).catch((e) => { - pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unable to overwrite context menus: ${e}` }); - return undefined; - }); - if (!setCommands) return; - - const setCommandsArray = [...setCommands.values()]; - await pluginData.state.contextMenuLinks.deleteAll(); - - for (let i = 0; i < setCommandsArray.length; i++) { - const command = setCommandsArray[i]; - pluginData.state.contextMenuLinks.create(command.id, addedNames[i]); - } -} diff --git a/backend/src/plugins/Counters/CountersPlugin.ts b/backend/src/plugins/Counters/CountersPlugin.ts index df321198..fd1f85bb 100644 --- a/backend/src/plugins/Counters/CountersPlugin.ts +++ b/backend/src/plugins/Counters/CountersPlugin.ts @@ -4,6 +4,7 @@ import { GuildCounters } from "../../data/GuildCounters.js"; import { CounterTrigger, parseCounterConditionString } from "../../data/entities/CounterTrigger.js"; import { makePublicFn } from "../../pluginUtils.js"; import { MINUTES, convertDelayStringToMS, values } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { AddCounterCmd } from "./commands/AddCounterCmd.js"; import { CountersListCmd } from "./commands/CountersListCmd.js"; import { ResetAllCounterValuesCmd } from "./commands/ResetAllCounterValuesCmd.js"; @@ -127,6 +128,10 @@ export const CountersPlugin = guildPlugin()({ await state.counters.markUnusedTriggersToBeDeleted(activeTriggerIds); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + async afterLoad(pluginData) { const { state } = pluginData; diff --git a/backend/src/plugins/Counters/commands/AddCounterCmd.ts b/backend/src/plugins/Counters/commands/AddCounterCmd.ts index c1ff37e7..1c463132 100644 --- a/backend/src/plugins/Counters/commands/AddCounterCmd.ts +++ b/backend/src/plugins/Counters/commands/AddCounterCmd.ts @@ -2,7 +2,6 @@ import { Snowflake, TextChannel } from "discord.js"; import { guildPluginMessageCommand } from "knub"; import { waitForReply } from "knub/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { UnknownUser, resolveUser } from "../../../utils.js"; import { changeCounterValue } from "../functions/changeCounterValue.js"; import { CountersPluginType } from "../types.js"; @@ -45,22 +44,22 @@ export const AddCounterCmd = guildPluginMessageCommand()({ const counter = config.counters[args.counterName]; const counterId = pluginData.state.counterIds[args.counterName]; if (!counter || !counterId) { - sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`); + void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`); return; } if (counter.can_edit === false) { - sendErrorMessage(pluginData, message.channel, `Missing permissions to edit this counter's value`); + void pluginData.state.common.sendErrorMessage(message, `Missing permissions to edit this counter's value`); return; } if (args.channel && !counter.per_channel) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`); return; } if (args.user && !counter.per_user) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-user`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`); return; } @@ -69,13 +68,13 @@ export const AddCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which channel's counter value would you like to add to?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake); if (!potentialChannel || !(potentialChannel instanceof TextChannel)) { - sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Channel is not a text channel, cancelling"); return; } @@ -87,13 +86,13 @@ export const AddCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which user's counter value would you like to add to?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialUser = await resolveUser(pluginData.client, reply.content); if (!potentialUser || potentialUser instanceof UnknownUser) { - sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Unknown user, cancelling"); return; } @@ -105,13 +104,13 @@ export const AddCounterCmd = guildPluginMessageCommand()({ message.channel.send("How much would you like to add to the counter's value?"); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialAmount = parseInt(reply.content, 10); if (!potentialAmount) { - sendErrorMessage(pluginData, message.channel, "Not a number, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Not a number, cancelling"); return; } diff --git a/backend/src/plugins/Counters/commands/CountersListCmd.ts b/backend/src/plugins/Counters/commands/CountersListCmd.ts index 396efae5..c92849fd 100644 --- a/backend/src/plugins/Counters/commands/CountersListCmd.ts +++ b/backend/src/plugins/Counters/commands/CountersListCmd.ts @@ -1,5 +1,4 @@ import { guildPluginMessageCommand } from "knub"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { trimMultilineString, ucfirst } from "../../../utils.js"; import { getGuildPrefix } from "../../../utils/getGuildPrefix.js"; import { CountersPluginType } from "../types.js"; @@ -15,7 +14,7 @@ export const CountersListCmd = guildPluginMessageCommand()({ const countersToShow = Array.from(Object.values(config.counters)).filter((c) => c.can_view !== false); if (!countersToShow.length) { - sendErrorMessage(pluginData, message.channel, "No counters are configured for this server"); + void pluginData.state.common.sendErrorMessage(message, "No counters are configured for this server"); return; } diff --git a/backend/src/plugins/Counters/commands/ResetAllCounterValuesCmd.ts b/backend/src/plugins/Counters/commands/ResetAllCounterValuesCmd.ts index 8b5a4520..7925511d 100644 --- a/backend/src/plugins/Counters/commands/ResetAllCounterValuesCmd.ts +++ b/backend/src/plugins/Counters/commands/ResetAllCounterValuesCmd.ts @@ -1,6 +1,5 @@ import { guildPluginMessageCommand } from "knub"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { confirm, noop, trimMultilineString } from "../../../utils.js"; import { resetAllCounterValues } from "../functions/resetAllCounterValues.js"; import { CountersPluginType } from "../types.js"; @@ -18,17 +17,20 @@ export const ResetAllCounterValuesCmd = guildPluginMessageCommand()({ const counter = config.counters[args.counterName]; const counterId = pluginData.state.counterIds[args.counterName]; if (!counter || !counterId) { - sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`); + void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`); return; } if (counter.can_edit === false) { - sendErrorMessage(pluginData, message.channel, `Missing permissions to reset this counter's value`); + void pluginData.state.common.sendErrorMessage(message, `Missing permissions to reset this counter's value`); return; } if (args.channel && !counter.per_channel) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`); return; } if (args.user && !counter.per_user) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-user`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`); return; } @@ -64,13 +63,13 @@ export const ResetCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which channel's counter value would you like to reset?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake); if (!potentialChannel || !(potentialChannel instanceof TextChannel)) { - sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Channel is not a text channel, cancelling"); return; } @@ -82,13 +81,13 @@ export const ResetCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which user's counter value would you like to reset?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialUser = await resolveUser(pluginData.client, reply.content); if (!potentialUser || potentialUser instanceof UnknownUser) { - sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Unknown user, cancelling"); return; } diff --git a/backend/src/plugins/Counters/commands/SetCounterCmd.ts b/backend/src/plugins/Counters/commands/SetCounterCmd.ts index a126e32f..0be13b7e 100644 --- a/backend/src/plugins/Counters/commands/SetCounterCmd.ts +++ b/backend/src/plugins/Counters/commands/SetCounterCmd.ts @@ -2,7 +2,6 @@ import { Snowflake, TextChannel } from "discord.js"; import { guildPluginMessageCommand } from "knub"; import { waitForReply } from "knub/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { UnknownUser, resolveUser } from "../../../utils.js"; import { setCounterValue } from "../functions/setCounterValue.js"; import { CountersPluginType } from "../types.js"; @@ -45,22 +44,22 @@ export const SetCounterCmd = guildPluginMessageCommand()({ const counter = config.counters[args.counterName]; const counterId = pluginData.state.counterIds[args.counterName]; if (!counter || !counterId) { - sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`); + void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`); return; } if (counter.can_edit === false) { - sendErrorMessage(pluginData, message.channel, `Missing permissions to edit this counter's value`); + void pluginData.state.common.sendErrorMessage(message, `Missing permissions to edit this counter's value`); return; } if (args.channel && !counter.per_channel) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`); return; } if (args.user && !counter.per_user) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-user`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`); return; } @@ -69,13 +68,13 @@ export const SetCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which channel's counter value would you like to change?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake); if (!potentialChannel || !(potentialChannel instanceof TextChannel)) { - sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Channel is not a text channel, cancelling"); return; } @@ -87,13 +86,13 @@ export const SetCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which user's counter value would you like to change?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialUser = await resolveUser(pluginData.client, reply.content); if (!potentialUser || potentialUser instanceof UnknownUser) { - sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Unknown user, cancelling"); return; } @@ -105,13 +104,13 @@ export const SetCounterCmd = guildPluginMessageCommand()({ message.channel.send("What would you like to set the counter's value to?"); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialValue = parseInt(reply.content, 10); if (Number.isNaN(potentialValue)) { - sendErrorMessage(pluginData, message.channel, "Not a number, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Not a number, cancelling"); return; } @@ -119,7 +118,7 @@ export const SetCounterCmd = guildPluginMessageCommand()({ } if (value < 0) { - sendErrorMessage(pluginData, message.channel, "Cannot set counter value below 0"); + void pluginData.state.common.sendErrorMessage(message, "Cannot set counter value below 0"); return; } diff --git a/backend/src/plugins/Counters/commands/ViewCounterCmd.ts b/backend/src/plugins/Counters/commands/ViewCounterCmd.ts index 1a26c04a..3691d53f 100644 --- a/backend/src/plugins/Counters/commands/ViewCounterCmd.ts +++ b/backend/src/plugins/Counters/commands/ViewCounterCmd.ts @@ -2,7 +2,6 @@ import { Snowflake } from "discord.js"; import { guildPluginMessageCommand } from "knub"; import { waitForReply } from "knub/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { resolveUser, UnknownUser } from "../../../utils.js"; import { CountersPluginType } from "../types.js"; @@ -39,22 +38,22 @@ export const ViewCounterCmd = guildPluginMessageCommand()({ const counter = config.counters[args.counterName]; const counterId = pluginData.state.counterIds[args.counterName]; if (!counter || !counterId) { - sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`); + void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`); return; } if (counter.can_view === false) { - sendErrorMessage(pluginData, message.channel, `Missing permissions to view this counter's value`); + void pluginData.state.common.sendErrorMessage(message, `Missing permissions to view this counter's value`); return; } if (args.channel && !counter.per_channel) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`); return; } if (args.user && !counter.per_user) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-user`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`); return; } @@ -63,13 +62,13 @@ export const ViewCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which channel's counter value would you like to view?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake); if (!potentialChannel?.isTextBased()) { - sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Channel is not a text channel, cancelling"); return; } @@ -81,13 +80,13 @@ export const ViewCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which user's counter value would you like to view?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialUser = await resolveUser(pluginData.client, reply.content); if (!potentialUser || potentialUser instanceof UnknownUser) { - sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Unknown user, cancelling"); return; } diff --git a/backend/src/plugins/Counters/types.ts b/backend/src/plugins/Counters/types.ts index 289d0612..647a752e 100644 --- a/backend/src/plugins/Counters/types.ts +++ b/backend/src/plugins/Counters/types.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "events"; -import { BasePluginType } from "knub"; +import { BasePluginType, pluginUtils } from "knub"; import z from "zod"; import { GuildCounters, MAX_COUNTER_VALUE, MIN_COUNTER_VALUE } from "../../data/GuildCounters.js"; import { @@ -9,6 +9,7 @@ import { parseCounterConditionString, } from "../../data/entities/CounterTrigger.js"; import { zBoundedCharacters, zBoundedRecord, zDelayString } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import Timeout = NodeJS.Timeout; const MAX_COUNTERS = 5; @@ -132,5 +133,6 @@ export interface CountersPluginType extends BasePluginType { decayTimers: Timeout[]; events: CounterEventEmitter; counterTriggersByCounterId: Map; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts index 6c0b9bf3..c61d66f4 100644 --- a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts +++ b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts @@ -11,6 +11,7 @@ import { messageToTemplateSafeMessage, userToTemplateSafeUser, } from "../../utils/templateSafeObjects.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { runEvent } from "./functions/runEvent.js"; import { CustomEventsPluginType, zCustomEventsConfig } from "./types.js"; @@ -28,6 +29,10 @@ export const CustomEventsPlugin = guildPlugin()({ configParser: (input) => zCustomEventsConfig.parse(input), defaultOptions, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const config = pluginData.config.get(); for (const [key, event] of Object.entries(config.events)) { diff --git a/backend/src/plugins/CustomEvents/functions/runEvent.ts b/backend/src/plugins/CustomEvents/functions/runEvent.ts index 12c0d777..c0247950 100644 --- a/backend/src/plugins/CustomEvents/functions/runEvent.ts +++ b/backend/src/plugins/CustomEvents/functions/runEvent.ts @@ -1,6 +1,5 @@ import { Message } from "discord.js"; import { GuildPluginData } from "knub"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { TemplateSafeValueContainer } from "../../../templateFormatter.js"; import { ActionError } from "../ActionError.js"; import { addRoleAction } from "../actions/addRoleAction.js"; @@ -39,7 +38,7 @@ export async function runEvent( } catch (e) { if (e instanceof ActionError) { if (event.trigger.type === "command") { - sendErrorMessage(pluginData, (eventData.msg as Message).channel, e.message); + void pluginData.state.common.sendErrorMessage((eventData.msg as Message).channel, e.message); } else { // TODO: Where to log action errors from other kinds of triggers? } diff --git a/backend/src/plugins/CustomEvents/types.ts b/backend/src/plugins/CustomEvents/types.ts index 3c71b77f..b9f31f64 100644 --- a/backend/src/plugins/CustomEvents/types.ts +++ b/backend/src/plugins/CustomEvents/types.ts @@ -1,6 +1,7 @@ -import { BasePluginType } from "knub"; +import { BasePluginType, pluginUtils } from "knub"; import z from "zod"; import { zBoundedCharacters, zBoundedRecord } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { zAddRoleAction } from "./actions/addRoleAction.js"; import { zCreateCaseAction } from "./actions/createCaseAction.js"; import { zMakeRoleMentionableAction } from "./actions/makeRoleMentionableAction.js"; @@ -43,5 +44,6 @@ export interface CustomEventsPluginType extends BasePluginType { config: z.infer; state: { clearTriggers: () => void; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/LocateUser/LocateUserPlugin.ts b/backend/src/plugins/LocateUser/LocateUserPlugin.ts index ebfe611f..b16a29a9 100644 --- a/backend/src/plugins/LocateUser/LocateUserPlugin.ts +++ b/backend/src/plugins/LocateUser/LocateUserPlugin.ts @@ -1,6 +1,7 @@ import { PluginOptions, guildPlugin } from "knub"; import { onGuildEvent } from "../../data/GuildEvents.js"; import { GuildVCAlerts } from "../../data/GuildVCAlerts.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { FollowCmd } from "./commands/FollowCmd.js"; import { DeleteFollowCmd, ListFollowCmd } from "./commands/ListFollowCmd.js"; import { WhereCmd } from "./commands/WhereCmd.js"; @@ -53,6 +54,10 @@ export const LocateUserPlugin = guildPlugin()({ state.usersWithAlerts = []; }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/LocateUser/commands/FollowCmd.ts b/backend/src/plugins/LocateUser/commands/FollowCmd.ts index 874cbcfa..ff18dabc 100644 --- a/backend/src/plugins/LocateUser/commands/FollowCmd.ts +++ b/backend/src/plugins/LocateUser/commands/FollowCmd.ts @@ -2,7 +2,6 @@ import humanizeDuration from "humanize-duration"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { registerExpiringVCAlert } from "../../../data/loops/expiringVCAlertsLoop.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { MINUTES, SECONDS } from "../../../utils.js"; import { locateUserCmd } from "../types.js"; @@ -27,7 +26,7 @@ export const FollowCmd = locateUserCmd({ const active = args.active || false; if (time < 30 * SECONDS) { - sendErrorMessage(pluginData, msg.channel, "Sorry, but the minimum duration for an alert is 30 seconds!"); + void pluginData.state.common.sendErrorMessage(msg, "Sorry, but the minimum duration for an alert is 30 seconds!"); return; } @@ -46,17 +45,15 @@ export const FollowCmd = locateUserCmd({ } if (active) { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Every time <@${args.member.id}> joins or switches VC in the next ${humanizeDuration( time, )} i will notify and move you.\nPlease make sure to be in a voice channel, otherwise i cannot move you!`, ); } else { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Every time <@${args.member.id}> joins or switches VC in the next ${humanizeDuration(time)} i will notify you`, ); } diff --git a/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts b/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts index a308dcd2..cd3d6d45 100644 --- a/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts +++ b/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts @@ -1,6 +1,5 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { clearExpiringVCAlert } from "../../../data/loops/expiringVCAlertsLoop.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { createChunkedMessage, sorter } from "../../../utils.js"; import { locateUserCmd } from "../types.js"; @@ -13,7 +12,7 @@ export const ListFollowCmd = locateUserCmd({ async run({ message: msg, pluginData }) { const alerts = await pluginData.state.alerts.getAlertsByRequestorId(msg.member.id); if (alerts.length === 0) { - sendErrorMessage(pluginData, msg.channel, "You have no active alerts!"); + void pluginData.state.common.sendErrorMessage(msg, "You have no active alerts!"); return; } @@ -46,7 +45,7 @@ export const DeleteFollowCmd = locateUserCmd({ alerts.sort(sorter("expires_at")); if (args.num > alerts.length || args.num <= 0) { - sendErrorMessage(pluginData, msg.channel, "Unknown alert!"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown alert!"); return; } @@ -54,6 +53,6 @@ export const DeleteFollowCmd = locateUserCmd({ clearExpiringVCAlert(toDelete); await pluginData.state.alerts.delete(toDelete.id); - sendSuccessMessage(pluginData, msg.channel, "Alert deleted"); + void pluginData.state.common.sendSuccessMessage(msg, "Alert deleted"); }, }); diff --git a/backend/src/plugins/LocateUser/types.ts b/backend/src/plugins/LocateUser/types.ts index 568249cd..bbcb6d88 100644 --- a/backend/src/plugins/LocateUser/types.ts +++ b/backend/src/plugins/LocateUser/types.ts @@ -1,6 +1,7 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { GuildVCAlerts } from "../../data/GuildVCAlerts.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zLocateUserConfig = z.strictObject({ can_where: z.boolean(), @@ -13,6 +14,7 @@ export interface LocateUserPluginType extends BasePluginType { alerts: GuildVCAlerts; usersWithAlerts: string[]; unregisterGuildEventListener: () => void; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/LocateUser/utils/moveMember.ts b/backend/src/plugins/LocateUser/utils/moveMember.ts index 1f6ed792..85b068c6 100644 --- a/backend/src/plugins/LocateUser/utils/moveMember.ts +++ b/backend/src/plugins/LocateUser/utils/moveMember.ts @@ -1,6 +1,5 @@ import { GuildMember, GuildTextBasedChannel, Snowflake } from "discord.js"; import { GuildPluginData } from "knub"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { LocateUserPluginType } from "../types.js"; export async function moveMember( @@ -16,10 +15,10 @@ export async function moveMember( channel: target.voice.channelId, }); } catch { - sendErrorMessage(pluginData, errorChannel, "Failed to move you. Are you in a voice channel?"); + void pluginData.state.common.sendErrorMessage(errorChannel, "Failed to move you. Are you in a voice channel?"); return; } } else { - sendErrorMessage(pluginData, errorChannel, "Failed to move you. Are you in a voice channel?"); + void pluginData.state.common.sendErrorMessage(errorChannel, "Failed to move you. Are you in a voice channel?"); } } diff --git a/backend/src/plugins/LocateUser/utils/sendWhere.ts b/backend/src/plugins/LocateUser/utils/sendWhere.ts index 1c12636b..ef74cbcd 100644 --- a/backend/src/plugins/LocateUser/utils/sendWhere.ts +++ b/backend/src/plugins/LocateUser/utils/sendWhere.ts @@ -1,7 +1,6 @@ import { GuildMember, GuildTextBasedChannel, Invite, VoiceChannel } from "discord.js"; import { GuildPluginData } from "knub"; import { getInviteLink } from "knub/helpers"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { LocateUserPluginType } from "../types.js"; import { createOrReuseInvite } from "./createOrReuseInvite.js"; @@ -22,7 +21,7 @@ export async function sendWhere( try { invite = await createOrReuseInvite(voice); } catch { - sendErrorMessage(pluginData, channel, "Cannot create an invite to that channel!"); + void pluginData.state.common.sendErrorMessage(channel, "Cannot create an invite to that channel!"); return; } channel.send({ diff --git a/backend/src/plugins/Logs/LogsPlugin.ts b/backend/src/plugins/Logs/LogsPlugin.ts index d45b86dc..9fc3e142 100644 --- a/backend/src/plugins/Logs/LogsPlugin.ts +++ b/backend/src/plugins/Logs/LogsPlugin.ts @@ -30,7 +30,7 @@ import { import { LogsThreadCreateEvt, LogsThreadDeleteEvt, LogsThreadUpdateEvt } from "./events/LogsThreadModifyEvts.js"; import { LogsGuildMemberUpdateEvt } from "./events/LogsUserUpdateEvts.js"; import { LogsVoiceStateUpdateEvt } from "./events/LogsVoiceChannelEvts.js"; -import { LogsPluginType, zLogsConfig } from "./types.js"; +import { FORMAT_NO_TIMESTAMP, LogsPluginType, zLogsConfig } from "./types.js"; import { getLogMessage } from "./util/getLogMessage.js"; import { log } from "./util/log.js"; import { onMessageDelete } from "./util/onMessageDelete.js"; diff --git a/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts b/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts index 1952d021..a712c60e 100644 --- a/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts +++ b/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts @@ -12,7 +12,7 @@ import { userToTemplateSafeUser, } from "../../../utils/templateSafeObjects.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; -import { LogsPluginType } from "../types.js"; +import { FORMAT_NO_TIMESTAMP, LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMessageDeleteData { diff --git a/backend/src/plugins/Logs/types.ts b/backend/src/plugins/Logs/types.ts index 11eadbd1..95754015 100644 --- a/backend/src/plugins/Logs/types.ts +++ b/backend/src/plugins/Logs/types.ts @@ -6,7 +6,7 @@ import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { LogType } from "../../data/LogType.js"; -import { zBoundedCharacters, zMessageContent, zRegex, zSnowflake } from "../../utils.js"; +import { keys, zBoundedCharacters, zMessageContent, zRegex, zSnowflake } from "../../utils.js"; import { MessageBuffer } from "../../utils/MessageBuffer.js"; import { TemplateSafeCase, diff --git a/backend/src/plugins/Logs/util/getLogMessage.ts b/backend/src/plugins/Logs/util/getLogMessage.ts index 30c92087..65d5a06c 100644 --- a/backend/src/plugins/Logs/util/getLogMessage.ts +++ b/backend/src/plugins/Logs/util/getLogMessage.ts @@ -25,7 +25,7 @@ import { TemplateSafeUser, } from "../../../utils/templateSafeObjects.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; -import { ILogTypeData, LogsPluginType, TLogChannel } from "../types.js"; +import { FORMAT_NO_TIMESTAMP, ILogTypeData, LogsPluginType, TLogChannel } from "../types.js"; export async function getLogMessage( pluginData: GuildPluginData, diff --git a/backend/src/plugins/Logs/util/onMessageUpdate.ts b/backend/src/plugins/Logs/util/onMessageUpdate.ts index 24b88aad..e5e1c019 100644 --- a/backend/src/plugins/Logs/util/onMessageUpdate.ts +++ b/backend/src/plugins/Logs/util/onMessageUpdate.ts @@ -1,6 +1,6 @@ import { EmbedData, GuildTextBasedChannel, Snowflake } from "discord.js"; import { GuildPluginData } from "knub"; -import cloneDeep from "lodash/cloneDeep.js"; +import cloneDeep from "lodash.clonedeep"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { resolveUser } from "../../../utils.js"; import { logMessageEdit } from "../logFunctions/logMessageEdit.js"; diff --git a/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts b/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts index ead63597..5196ef23 100644 --- a/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts +++ b/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts @@ -1,13 +1,9 @@ import { PluginOptions, guildPlugin } from "knub"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { SaveMessagesToDBCmd } from "./commands/SaveMessagesToDB.js"; import { SavePinsToDBCmd } from "./commands/SavePinsToDB.js"; -import { - MessageCreateEvt, - MessageDeleteBulkEvt, - MessageDeleteEvt, - MessageUpdateEvt, -} from "./events/SaveMessagesEvts.js"; +import { MessageCreateEvt, MessageDeleteBulkEvt, MessageDeleteEvt, MessageUpdateEvt } from "./events/SaveMessagesEvts.js"; import { MessageSaverPluginType, zMessageSaverConfig } from "./types.js"; const defaultOptions: PluginOptions = { @@ -48,4 +44,8 @@ export const MessageSaverPlugin = guildPlugin()({ const { state, guild } = pluginData; state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); }, + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/MessageSaver/commands/SaveMessagesToDB.ts b/backend/src/plugins/MessageSaver/commands/SaveMessagesToDB.ts index 120d4c80..87543b02 100644 --- a/backend/src/plugins/MessageSaver/commands/SaveMessagesToDB.ts +++ b/backend/src/plugins/MessageSaver/commands/SaveMessagesToDB.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendSuccessMessage } from "../../../pluginUtils.js"; import { saveMessagesToDB } from "../saveMessagesToDB.js"; import { messageSaverCmd } from "../types.js"; @@ -18,13 +17,12 @@ export const SaveMessagesToDBCmd = messageSaverCmd({ const { savedCount, failed } = await saveMessagesToDB(pluginData, args.channel, args.ids.trim().split(" ")); if (failed.length) { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(", ")}`, ); } else { - sendSuccessMessage(pluginData, msg.channel, `Saved ${savedCount} messages!`); + void pluginData.state.common.sendSuccessMessage(msg, `Saved ${savedCount} messages!`); } }, }); diff --git a/backend/src/plugins/MessageSaver/commands/SavePinsToDB.ts b/backend/src/plugins/MessageSaver/commands/SavePinsToDB.ts index 67be1bb8..ab903581 100644 --- a/backend/src/plugins/MessageSaver/commands/SavePinsToDB.ts +++ b/backend/src/plugins/MessageSaver/commands/SavePinsToDB.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendSuccessMessage } from "../../../pluginUtils.js"; import { saveMessagesToDB } from "../saveMessagesToDB.js"; import { messageSaverCmd } from "../types.js"; @@ -19,13 +18,12 @@ export const SavePinsToDBCmd = messageSaverCmd({ const { savedCount, failed } = await saveMessagesToDB(pluginData, args.channel, [...pins.keys()]); if (failed.length) { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(", ")}`, ); } else { - sendSuccessMessage(pluginData, msg.channel, `Saved ${savedCount} messages!`); + void pluginData.state.common.sendSuccessMessage(msg, `Saved ${savedCount} messages!`); } }, }); diff --git a/backend/src/plugins/MessageSaver/types.ts b/backend/src/plugins/MessageSaver/types.ts index 52d7e86b..1102a9ac 100644 --- a/backend/src/plugins/MessageSaver/types.ts +++ b/backend/src/plugins/MessageSaver/types.ts @@ -1,6 +1,7 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zMessageSaverConfig = z.strictObject({ can_manage: z.boolean(), @@ -10,6 +11,7 @@ export interface MessageSaverPluginType extends BasePluginType { config: z.infer; state: { savedMessages: GuildSavedMessages; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index aa379acb..798f2f12 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -10,44 +10,69 @@ import { GuildTempbans } from "../../data/GuildTempbans.js"; import { makePublicFn, mapToPublicFn } from "../../pluginUtils.js"; import { MINUTES } from "../../utils.js"; import { CasesPlugin } from "../Cases/CasesPlugin.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { MutesPlugin } from "../Mutes/MutesPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; -import { AddCaseCmd } from "./commands/AddCaseCmd.js"; -import { BanCmd } from "./commands/BanCmd.js"; -import { CaseCmd } from "./commands/CaseCmd.js"; -import { CasesModCmd } from "./commands/CasesModCmd.js"; -import { CasesUserCmd } from "./commands/CasesUserCmd.js"; -import { DeleteCaseCmd } from "./commands/DeleteCaseCmd.js"; -import { ForcebanCmd } from "./commands/ForcebanCmd.js"; -import { ForcemuteCmd } from "./commands/ForcemuteCmd.js"; -import { ForceUnmuteCmd } from "./commands/ForceunmuteCmd.js"; -import { HideCaseCmd } from "./commands/HideCaseCmd.js"; -import { KickCmd } from "./commands/KickCmd.js"; -import { MassbanCmd } from "./commands/MassBanCmd.js"; -import { MassunbanCmd } from "./commands/MassUnbanCmd.js"; -import { MassmuteCmd } from "./commands/MassmuteCmd.js"; -import { MuteCmd } from "./commands/MuteCmd.js"; -import { NoteCmd } from "./commands/NoteCmd.js"; -import { SoftbanCmd } from "./commands/SoftbanCommand.js"; -import { UnbanCmd } from "./commands/UnbanCmd.js"; -import { UnhideCaseCmd } from "./commands/UnhideCaseCmd.js"; -import { UnmuteCmd } from "./commands/UnmuteCmd.js"; -import { UpdateCmd } from "./commands/UpdateCmd.js"; -import { WarnCmd } from "./commands/WarnCmd.js"; +import { AddCaseMsgCmd } from "./commands/addcase/AddCaseMsgCmd.js"; +import { AddCaseSlashCmd } from "./commands/addcase/AddCaseSlashCmd.js"; +import { BanMsgCmd } from "./commands/ban/BanMsgCmd.js"; +import { BanSlashCmd } from "./commands/ban/BanSlashCmd.js"; +import { CaseMsgCmd } from "./commands/case/CaseMsgCmd.js"; +import { CaseSlashCmd } from "./commands/case/CaseSlashCmd.js"; +import { CasesModMsgCmd } from "./commands/cases/CasesModMsgCmd.js"; +import { CasesSlashCmd } from "./commands/cases/CasesSlashCmd.js"; +import { CasesUserMsgCmd } from "./commands/cases/CasesUserMsgCmd.js"; +import { DeleteCaseMsgCmd } from "./commands/deletecase/DeleteCaseMsgCmd.js"; +import { DeleteCaseSlashCmd } from "./commands/deletecase/DeleteCaseSlashCmd.js"; +import { ForceBanMsgCmd } from "./commands/forceban/ForceBanMsgCmd.js"; +import { ForceBanSlashCmd } from "./commands/forceban/ForceBanSlashCmd.js"; +import { ForceMuteMsgCmd } from "./commands/forcemute/ForceMuteMsgCmd.js"; +import { ForceMuteSlashCmd } from "./commands/forcemute/ForceMuteSlashCmd.js"; +import { ForceUnmuteMsgCmd } from "./commands/forceunmute/ForceUnmuteMsgCmd.js"; +import { ForceUnmuteSlashCmd } from "./commands/forceunmute/ForceUnmuteSlashCmd.js"; +import { HideCaseMsgCmd } from "./commands/hidecase/HideCaseMsgCmd.js"; +import { HideCaseSlashCmd } from "./commands/hidecase/HideCaseSlashCmd.js"; +import { KickMsgCmd } from "./commands/kick/KickMsgCmd.js"; +import { KickSlashCmd } from "./commands/kick/KickSlashCmd.js"; +import { MassBanMsgCmd } from "./commands/massban/MassBanMsgCmd.js"; +import { MassBanSlashCmd } from "./commands/massban/MassBanSlashCmd.js"; +import { MassMuteMsgCmd } from "./commands/massmute/MassMuteMsgCmd.js"; +import { MassMuteSlashSlashCmd } from "./commands/massmute/MassMuteSlashCmd.js"; +import { MassUnbanMsgCmd } from "./commands/massunban/MassUnbanMsgCmd.js"; +import { MassUnbanSlashCmd } from "./commands/massunban/MassUnbanSlashCmd.js"; +import { MuteMsgCmd } from "./commands/mute/MuteMsgCmd.js"; +import { MuteSlashCmd } from "./commands/mute/MuteSlashCmd.js"; +import { NoteMsgCmd } from "./commands/note/NoteMsgCmd.js"; +import { NoteSlashCmd } from "./commands/note/NoteSlashCmd.js"; +import { UnbanMsgCmd } from "./commands/unban/UnbanMsgCmd.js"; +import { UnbanSlashCmd } from "./commands/unban/UnbanSlashCmd.js"; +import { UnhideCaseMsgCmd } from "./commands/unhidecase/UnhideCaseMsgCmd.js"; +import { UnhideCaseSlashCmd } from "./commands/unhidecase/UnhideCaseSlashCmd.js"; +import { UnmuteMsgCmd } from "./commands/unmute/UnmuteMsgCmd.js"; +import { UnmuteSlashCmd } from "./commands/unmute/UnmuteSlashCmd.js"; +import { UpdateMsgCmd } from "./commands/update/UpdateMsgCmd.js"; +import { UpdateSlashCmd } from "./commands/update/UpdateSlashCmd.js"; +import { WarnMsgCmd } from "./commands/warn/WarnMsgCmd.js"; +import { WarnSlashCmd } from "./commands/warn/WarnSlashCmd.js"; import { AuditLogEvents } from "./events/AuditLogEvents.js"; import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt.js"; import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt.js"; import { PostAlertOnMemberJoinEvt } from "./events/PostAlertOnMemberJoinEvt.js"; import { banUserId } from "./functions/banUserId.js"; import { clearTempban } from "./functions/clearTempban.js"; -import { hasMutePermission } from "./functions/hasMutePerm.js"; +import { + hasBanPermission, + hasMutePermission, + hasNotePermission, + hasWarnPermission, +} from "./functions/hasModActionPerm.js"; import { kickMember } from "./functions/kickMember.js"; import { offModActionsEvent } from "./functions/offModActionsEvent.js"; import { onModActionsEvent } from "./functions/onModActionsEvent.js"; import { updateCase } from "./functions/updateCase.js"; import { warnMember } from "./functions/warnMember.js"; -import { ModActionsPluginType, zModActionsConfig } from "./types.js"; +import { AttachmentLinkReactionType, ModActionsPluginType, modActionsSlashGroup, zModActionsConfig } from "./types.js"; const defaultOptions = { config: { @@ -69,6 +94,7 @@ const defaultOptions = { warn_notify_message: "The user already has **{priorWarnings}** warnings!\n Please check their prior cases and assess whether or not to warn anyways.\n Proceed with the warning?", ban_delete_message_days: 1, + attachment_link_reaction: "warn" as AttachmentLinkReactionType, can_note: false, can_warn: false, @@ -122,29 +148,58 @@ export const ModActionsPlugin = guildPlugin()({ events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt, AuditLogEvents], + slashCommands: [ + modActionsSlashGroup({ + name: "mod", + description: "Moderation actions", + defaultMemberPermissions: "0", + subcommands: [ + AddCaseSlashCmd, + BanSlashCmd, + CaseSlashCmd, + CasesSlashCmd, + DeleteCaseSlashCmd, + ForceBanSlashCmd, + ForceMuteSlashCmd, + ForceUnmuteSlashCmd, + HideCaseSlashCmd, + KickSlashCmd, + MassBanSlashCmd, + MassMuteSlashSlashCmd, + MassUnbanSlashCmd, + MuteSlashCmd, + NoteSlashCmd, + UnbanSlashCmd, + UnhideCaseSlashCmd, + UnmuteSlashCmd, + UpdateSlashCmd, + WarnSlashCmd, + ], + }), + ], + messageCommands: [ - UpdateCmd, - NoteCmd, - WarnCmd, - MuteCmd, - ForcemuteCmd, - UnmuteCmd, - ForceUnmuteCmd, - KickCmd, - SoftbanCmd, - BanCmd, - UnbanCmd, - ForcebanCmd, - MassbanCmd, - MassmuteCmd, - MassunbanCmd, - AddCaseCmd, - CaseCmd, - CasesUserCmd, - CasesModCmd, - HideCaseCmd, - UnhideCaseCmd, - DeleteCaseCmd, + UpdateMsgCmd, + NoteMsgCmd, + WarnMsgCmd, + MuteMsgCmd, + ForceMuteMsgCmd, + UnmuteMsgCmd, + ForceUnmuteMsgCmd, + KickMsgCmd, + BanMsgCmd, + UnbanMsgCmd, + ForceBanMsgCmd, + MassBanMsgCmd, + MassMuteMsgCmd, + MassUnbanMsgCmd, + AddCaseMsgCmd, + CaseMsgCmd, + CasesUserMsgCmd, + CasesModMsgCmd, + HideCaseMsgCmd, + UnhideCaseMsgCmd, + DeleteCaseMsgCmd, ], public(pluginData) { @@ -153,8 +208,11 @@ export const ModActionsPlugin = guildPlugin()({ kickMember: makePublicFn(pluginData, kickMember), banUserId: makePublicFn(pluginData, banUserId), updateCase: (msg: Message, caseNumber: number | null, note: string) => - updateCase(pluginData, msg, { caseNumber, note }), + updateCase(pluginData, msg, msg.author, caseNumber ?? undefined, note, [...msg.attachments.values()]), + hasNotePermission: makePublicFn(pluginData, hasNotePermission), + hasWarnPermission: makePublicFn(pluginData, hasWarnPermission), hasMutePermission: makePublicFn(pluginData, hasMutePermission), + hasBanPermission: makePublicFn(pluginData, hasBanPermission), on: mapToPublicFn(onModActionsEvent), off: mapToPublicFn(offModActionsEvent), getEventEmitter: () => pluginData.state.events, @@ -178,6 +236,10 @@ export const ModActionsPlugin = guildPlugin()({ state.events = new EventEmitter(); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts b/backend/src/plugins/ModActions/commands/AddCaseCmd.ts deleted file mode 100644 index cba64f78..00000000 --- a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { Case } from "../../../data/entities/Case.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { renderUsername, resolveMember, resolveUser } from "../../../utils.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), -}; - -export const AddCaseCmd = modActionsCmd({ - 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 **${renderUsername(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 d3c5c745..00000000 --- a/backend/src/plugins/ModActions/commands/BanCmd.ts +++ /dev/null @@ -1,219 +0,0 @@ -import humanizeDuration from "humanize-duration"; -import { getMemberLevel } from "knub/helpers"; -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { clearExpiringTempban, registerExpiringTempban } from "../../../data/loops/expiringTempbansLoop.js"; -import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { renderUsername, resolveMember, resolveUser } from "../../../utils.js"; -import { banLock } from "../../../utils/lockNameHelpers.js"; -import { waitForButtonConfirm } from "../../../utils/waitForInteraction.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { banUserId } from "../functions/banUserId.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { isBanned } from "../functions/isBanned.js"; -import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs.js"; -import { modActionsCmd } from "../types.js"; - -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 = modActionsCmd({ - 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 time = args["time"] ? args["time"] : null; - - const reason = formatReasonWithAttachments(args.reason, [...msg.attachments.values()]); - 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; - } - - // 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) { - // 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; - } else { - // 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; - } - } else { - // 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; - } - } - } - - // 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 **${renderUsername(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 3a906df6..00000000 --- a/backend/src/plugins/ModActions/commands/CaseCmd.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; -import { modActionsCmd } from "../types.js"; - -export const CaseCmd = modActionsCmd({ - 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 ab585ef8..00000000 --- a/backend/src/plugins/ModActions/commands/CasesModCmd.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { APIEmbed } from "discord.js"; -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; -import { UnknownUser, emptyEmbedValue, renderUsername, resolveMember, resolveUser, trimLines } from "../../../utils.js"; -import { asyncMap } from "../../../utils/async.js"; -import { createPaginatedMessage } from "../../../utils/createPaginatedMessage.js"; -import { getGuildPrefix } from "../../../utils/getGuildPrefix.js"; -import { CasesPlugin } from "../../Cases/CasesPlugin.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.userId({ option: true }), -}; - -const casesPerPage = 5; - -export const CasesModCmd = modActionsCmd({ - 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 resolveMember(pluginData.client, pluginData.guild, modId)) || - (await resolveUser(pluginData.client, modId)); - const modName = mod instanceof UnknownUser ? modId : renderUsername(mod); - - 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 isLastPage = page === totalPages; - const firstCaseNum = (page - 1) * casesPerPage + 1; - const lastCaseNum = isLastPage ? totalCases : page * casesPerPage; - const title = `Most recent cases ${firstCaseNum}-${lastCaseNum} of ${totalCases} by ${modName}`; - - const embed = { - author: { - name: title, - icon_url: mod instanceof UnknownUser ? undefined : mod.displayAvatarURL(), - }, - description: lines.join("\n"), - fields: [ - { - 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 c836c079..00000000 --- a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { APIEmbed, User } from "discord.js"; -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { - UnknownUser, - chunkArray, - emptyEmbedValue, - renderUsername, - resolveMember, - resolveUser, -} from "../../../utils.js"; -import { asyncMap } from "../../../utils/async.js"; -import { createPaginatedMessage } from "../../../utils/createPaginatedMessage.js"; -import { getGuildPrefix } from "../../../utils/getGuildPrefix.js"; -import { modActionsCmd } from "../types.js"; - -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" }), -}; - -const casesPerPage = 5; - -export const CasesUserCmd = modActionsCmd({ - 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 resolveMember(pluginData.client, pluginData.guild, args.user)) || - (await resolveUser(pluginData.client, args.user)); - if (user instanceof UnknownUser) { - 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 : renderUsername(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 totalPages = Math.max(Math.ceil(casesToDisplay.length / casesPerPage), 1); - const prefix = getGuildPrefix(pluginData); - - createPaginatedMessage( - pluginData.client, - msg.channel, - totalPages, - async (page) => { - const chunkedCases = chunkArray(casesToDisplay, casesPerPage)[page - 1]; - const lines = await asyncMap(chunkedCases, (c) => casesPlugin.getCaseSummary(c, true, msg.author.id)); - - const isLastPage = page === totalPages; - const firstCaseNum = (page - 1) * casesPerPage + 1; - const lastCaseNum = isLastPage ? casesToDisplay.length : page * casesPerPage; - const title = - totalPages === 1 - ? `Cases for ${userName} (${lines.length} total)` - : `Most recent cases ${firstCaseNum}-${lastCaseNum} of ${casesToDisplay.length} for ${userName}`; - - const embed = { - author: { - name: title, - icon_url: user instanceof User ? user.displayAvatarURL() : undefined, - }, - description: lines.join("\n"), - fields: [ - { - name: emptyEmbedValue, - value: `Use \`${prefix}case \` to see more information about an individual case`, - }, - ], - } satisfies APIEmbed; - - if (isLastPage && !args.hidden && hiddenCases.length) - embed.fields.push({ - name: emptyEmbedValue, - value: - hiddenCases.length === 1 - ? `*+${hiddenCases.length} hidden case, use "-hidden" to show it*` - : `*+${hiddenCases.length} hidden cases, use "-hidden" to show them*`, - }); - - return { embeds: [embed] }; - }, - { - limitToUserId: msg.author.id, - }, - ); - } - } - }, -}); diff --git a/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts b/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts deleted file mode 100644 index 82b91d85..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.js"; -import { Case } from "../../../data/entities/Case.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { SECONDS, renderUsername, trimLines } from "../../../utils.js"; -import { CasesPlugin } from "../../Cases/CasesPlugin.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; -import { modActionsCmd } from "../types.js"; - -export const DeleteCaseCmd = modActionsCmd({ - 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 = renderUsername(message.author); - - 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 c51487e4..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.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { LogType } from "../../../data/LogType.js"; -import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { DAYS, MINUTES, resolveMember, resolveUser } from "../../../utils.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { ignoreEvent } from "../functions/ignoreEvent.js"; -import { isBanned } from "../functions/isBanned.js"; -import { IgnoredEventType, modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), -}; - -export const ForcebanCmd = modActionsCmd({ - 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 c1363fc3..00000000 --- a/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils.js"; -import { resolveMember, resolveUser } from "../../../utils.js"; -import { actualMuteUserCmd } from "../functions/actualMuteUserCmd.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), - notify: ct.string({ option: true }), - "notify-channel": ct.textChannel({ option: true }), -}; - -export const ForcemuteCmd = modActionsCmd({ - 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/ForceunmuteCmd.ts b/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts deleted file mode 100644 index 6b60791a..00000000 --- a/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils.js"; -import { resolveMember, resolveUser } from "../../../utils.js"; -import { actualUnmuteCmd } from "../functions/actualUnmuteUserCmd.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), -}; - -export const ForceUnmuteCmd = modActionsCmd({ - trigger: "forceunmute", - permission: "can_mute", - description: "Force-unmute the specified user, even if they're not on the server", - - signature: [ - { - user: ct.string(), - time: ct.delay(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - { - user: ct.string(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - ], - - async run({ pluginData, message: msg, args }) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - // Check if they're muted in the first place - if (!(await pluginData.state.mutes.isMuted(user.id))) { - sendErrorMessage(pluginData, msg.channel, "Cannot unmute: member is not muted"); - return; - } - - // Find the server member to unmute - const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id); - - // Make sure we're allowed to unmute this member - if (memberToUnmute && !canActOn(pluginData, msg.member, memberToUnmute)) { - sendErrorMessage(pluginData, msg.channel, "Cannot unmute: insufficient permissions"); - return; - } - - actualUnmuteCmd(pluginData, user, msg, args); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/HideCaseCmd.ts b/backend/src/plugins/ModActions/commands/HideCaseCmd.ts deleted file mode 100644 index 790b1feb..00000000 --- a/backend/src/plugins/ModActions/commands/HideCaseCmd.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { modActionsCmd } from "../types.js"; - -export const HideCaseCmd = modActionsCmd({ - 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 33528844..00000000 --- a/backend/src/plugins/ModActions/commands/KickCmd.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { actualKickMemberCmd } from "../functions/actualKickMemberCmd.js"; -import { modActionsCmd } from "../types.js"; - -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 = modActionsCmd({ - 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 8f4fdccb..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.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { LogType } from "../../../data/LogType.js"; -import { humanizeDurationShort } from "../../../humanizeDurationShort.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { DAYS, MINUTES, SECONDS, noop } from "../../../utils.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { ignoreEvent } from "../functions/ignoreEvent.js"; -import { IgnoredEventType, modActionsCmd } from "../types.js"; - -export const MassbanCmd = modActionsCmd({ - 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 dfe19f67..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.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { LogType } from "../../../data/LogType.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../Cases/CasesPlugin.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { ignoreEvent } from "../functions/ignoreEvent.js"; -import { isBanned } from "../functions/isBanned.js"; -import { IgnoredEventType, modActionsCmd } from "../types.js"; - -export const MassunbanCmd = modActionsCmd({ - 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 4cb26485..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.js"; -import { LogType } from "../../../data/LogType.js"; -import { logger } from "../../../logger.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { modActionsCmd } from "../types.js"; - -export const MassmuteCmd = modActionsCmd({ - 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/MuteCmd.ts b/backend/src/plugins/ModActions/commands/MuteCmd.ts deleted file mode 100644 index bfefcec6..00000000 --- a/backend/src/plugins/ModActions/commands/MuteCmd.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils.js"; -import { resolveMember, resolveUser } from "../../../utils.js"; -import { waitForButtonConfirm } from "../../../utils/waitForInteraction.js"; -import { actualMuteUserCmd } from "../functions/actualMuteUserCmd.js"; -import { isBanned } from "../functions/isBanned.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), - notify: ct.string({ option: true }), - "notify-channel": ct.textChannel({ option: true }), -}; - -export const MuteCmd = modActionsCmd({ - trigger: "mute", - permission: "can_mute", - description: "Mute 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 memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); - - if (!memberToMute) { - const _isBanned = await isBanned(pluginData, user.id); - const prefix = pluginData.fullConfig.prefix; - if (_isBanned) { - sendErrorMessage( - pluginData, - msg.channel, - `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( - msg.channel, - { content: "User not found on the server, forcemute instead?" }, - { confirmText: "Yes", cancelText: "No", restrictToId: msg.member.id }, - ); - - if (!reply) { - sendErrorMessage(pluginData, msg.channel, "User not on server, mute cancelled by moderator"); - return; - } - } - } - - // Make sure we're allowed to mute this member - if (memberToMute && !canActOn(pluginData, msg.member, memberToMute)) { - sendErrorMessage(pluginData, msg.channel, "Cannot mute: insufficient permissions"); - return; - } - - actualMuteUserCmd(pluginData, user, msg, args); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/NoteCmd.ts b/backend/src/plugins/ModActions/commands/NoteCmd.ts deleted file mode 100644 index 7f3f9219..00000000 --- a/backend/src/plugins/ModActions/commands/NoteCmd.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { renderUsername, resolveUser } from "../../../utils.js"; -import { CasesPlugin } from "../../Cases/CasesPlugin.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { modActionsCmd } from "../types.js"; - -export const NoteCmd = modActionsCmd({ - trigger: "note", - permission: "can_note", - description: "Add a note to the specified user", - - signature: { - user: ct.string(), - note: ct.string({ required: false, catchAll: 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; - } - - if (!args.note && msg.attachments.size === 0) { - sendErrorMessage(pluginData, msg.channel, "Text or attachment required"); - return; - } - - const userName = renderUsername(user); - const reason = formatReasonWithAttachments(args.note, [...msg.attachments.values()]); - - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const createdCase = await casesPlugin.createCase({ - userId: user.id, - modId: msg.author.id, - type: CaseTypes.Note, - reason, - }); - - pluginData.getPlugin(LogsPlugin).logMemberNote({ - mod: msg.author, - user, - caseNumber: createdCase.case_number, - reason, - }); - - sendSuccessMessage(pluginData, msg.channel, `Note added on **${userName}** (Case #${createdCase.case_number})`); - - pluginData.state.events.emit("note", user.id, reason); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/SoftbanCommand.ts b/backend/src/plugins/ModActions/commands/SoftbanCommand.ts deleted file mode 100644 index d59c63c1..00000000 --- a/backend/src/plugins/ModActions/commands/SoftbanCommand.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { trimPluginDescription } from "../../../utils.js"; -import { actualKickMemberCmd } from "../functions/actualKickMemberCmd.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), - notify: ct.string({ option: true }), - "notify-channel": ct.textChannel({ option: true }), -}; - -export const SoftbanCmd = modActionsCmd({ - 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 1a5a96b8..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.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { LogType } from "../../../data/LogType.js"; -import { clearExpiringTempban } from "../../../data/loops/expiringTempbansLoop.js"; -import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { resolveUser } from "../../../utils.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { ignoreEvent } from "../functions/ignoreEvent.js"; -import { IgnoredEventType, modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), -}; - -export const UnbanCmd = modActionsCmd({ - 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 0b47595d..00000000 --- a/backend/src/plugins/ModActions/commands/UnhideCaseCmd.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { modActionsCmd } from "../types.js"; - -export const UnhideCaseCmd = modActionsCmd({ - 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 1fcad2d9..00000000 --- a/backend/src/plugins/ModActions/commands/WarnCmd.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { errorMessage, renderUsername, resolveMember, resolveUser } from "../../../utils.js"; -import { waitForButtonConfirm } from "../../../utils/waitForInteraction.js"; -import { CasesPlugin } from "../../Cases/CasesPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { isBanned } from "../functions/isBanned.js"; -import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs.js"; -import { warnMember } from "../functions/warnMember.js"; -import { modActionsCmd } from "../types.js"; - -export const WarnCmd = modActionsCmd({ - 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 **${renderUsername(memberToWarn)}** (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..4c69967e --- /dev/null +++ b/backend/src/plugins/ModActions/commands/addcase/AddCaseMsgCmd.ts @@ -0,0 +1,63 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { resolveUser } from "../../../../utils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualAddCaseCmd } from "./actualAddCaseCmd.js"; + +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) { + pluginData.state.common.sendErrorMessage(msg, `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 }))) { + pluginData.state.common.sendErrorMessage(msg, "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]) { + pluginData.state.common.sendErrorMessage(msg, "Cannot add case: invalid case type"); + return; + } + + actualAddCaseCmd( + pluginData, + msg, + 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..e84f609b --- /dev/null +++ b/backend/src/plugins/ModActions/commands/addcase/AddCaseSlashCmd.ts @@ -0,0 +1,69 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualAddCaseCmd } from "./actualAddCaseCmd.js"; + +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", + }), +]; + +export const AddCaseSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: true }); + 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 as GuildMember; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + } + + actualAddCaseCmd( + pluginData, + interaction, + interaction.member as GuildMember, + mod, + attachments, + options.user, + options.type as keyof CaseTypes, + options.reason || "", + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/addcase/actualAddCaseCmd.ts b/backend/src/plugins/ModActions/commands/addcase/actualAddCaseCmd.ts new file mode 100644 index 00000000..b6ec6a56 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/addcase/actualAddCaseCmd.ts @@ -0,0 +1,63 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { Case } from "../../../../data/entities/Case.js"; +import { canActOn } from "../../../../pluginUtils.js"; +import { UnknownUser, renderUsername, resolveMember } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualAddCaseCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + author: GuildMember, + mod: GuildMember, + attachments: Array, + user: User | UnknownUser, + type: keyof CaseTypes, + reason: string, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + 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, author, member)) { + pluginData.state.common.sendErrorMessage(context, "Cannot add case on this user: insufficient permissions"); + return; + } + + const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, 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) { + pluginData.state.common.sendSuccessMessage( + context, + `Case #${theCase.case_number} created for **${renderUsername(user)}**`, + ); + } else { + pluginData.state.common.sendSuccessMessage(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/commands/ban/BanMsgCmd.ts b/backend/src/plugins/ModActions/commands/ban/BanMsgCmd.ts new file mode 100644 index 00000000..c02fe298 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/ban/BanMsgCmd.ts @@ -0,0 +1,75 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { UserNotificationMethod, resolveUser } from "../../../../utils.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualBanCmd } from "./actualBanCmd.js"; + +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) { + pluginData.state.common.sendErrorMessage(msg, `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 }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(args) ?? undefined; + } catch (e) { + pluginData.state.common.sendErrorMessage(msg, e.message); + return; + } + + actualBanCmd( + pluginData, + msg, + 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..0732d774 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/ban/BanSlashCmd.ts @@ -0,0 +1,100 @@ +import { ChannelType, GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { UserNotificationMethod, convertDelayStringToMS, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualBanCmd } from "./actualBanCmd.js"; + +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", + }), +]; + +export const BanSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member as GuildMember; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + pluginData.state.common.sendErrorMessage(interaction, e.message); + return; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) : null; + if (options.time && !convertedTime) { + pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); + return; + } + + actualBanCmd( + pluginData, + interaction, + options.user, + convertedTime, + options.reason || "", + attachments, + interaction.member as GuildMember, + mod, + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/ban/actualBanCmd.ts b/backend/src/plugins/ModActions/commands/ban/actualBanCmd.ts new file mode 100644 index 00000000..60903e44 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/ban/actualBanCmd.ts @@ -0,0 +1,190 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; +import humanizeDuration from "humanize-duration"; +import { GuildPluginData } from "knub"; +import { getMemberLevel } from "knub/helpers"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { clearExpiringTempban, registerExpiringTempban } from "../../../../data/loops/expiringTempbansLoop.js"; +import { canActOn, getContextChannel } from "../../../../pluginUtils.js"; +import { UnknownUser, UserNotificationMethod, renderUsername, resolveMember } from "../../../../utils.js"; +import { banLock } from "../../../../utils/lockNameHelpers.js"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { banUserId } from "../../functions/banUserId.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualBanCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + user: User | UnknownUser, + time: number | null, + reason: string, + attachments: Attachment[], + author: GuildMember, + mod: GuildMember, + contactMethods?: UserNotificationMethod[], + deleteDays?: number, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const memberToBan = await resolveMember(pluginData.client, pluginData.guild, user.id); + const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + const formattedReasonWithAttachments = 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) { + pluginData.state.common.sendErrorMessage(context, "User not on server, ban cancelled by moderator"); + lock.unlock(); + return; + } else { + forceban = true; + } + } else { + // Abort if trying to ban user indefinitely if they are already banned indefinitely + if (!existingTempban && !time) { + pluginData.state.common.sendErrorMessage(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) { + pluginData.state.common.sendErrorMessage(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, + }); + } + + pluginData.state.common.sendSuccessMessage( + 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!); + pluginData.state.common.sendErrorMessage( + 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: await getContextChannel(context), + }); + const deleteMessageDays = deleteDays ?? matchingConfig.ban_delete_message_days; + const banResult = await banUserId( + pluginData, + user.id, + formattedReason, + formattedReasonWithAttachments, + { + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== author.id ? author.id : undefined, + }, + deleteMessageDays, + modId: mod.id, + }, + time ?? undefined, + ); + + if (banResult.status === "failed") { + pluginData.state.common.sendErrorMessage(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 **${renderUsername(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(); + pluginData.state.common.sendSuccessMessage(context, response); +} 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..211e529f --- /dev/null +++ b/backend/src/plugins/ModActions/commands/case/CaseMsgCmd.ts @@ -0,0 +1,25 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualCaseCmd } from "./actualCaseCmd.js"; + +const opts = { + show: ct.switchOption({ def: false, shortcut: "sh" }), +}; + +export const CaseMsgCmd = modActionsMsgCmd({ + trigger: "case", + permission: "can_view", + description: "Show information about a specific case", + + signature: [ + { + caseNumber: ct.number(), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + actualCaseCmd(pluginData, msg, msg.author.id, args.caseNumber, args.show); + }, +}); 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..c2c7eccc --- /dev/null +++ b/backend/src/plugins/ModActions/commands/case/CaseSlashCmd.ts @@ -0,0 +1,25 @@ +import { slashOptions } from "knub"; +import { modActionsSlashCmd } from "../../types.js"; +import { actualCaseCmd } from "./actualCaseCmd.js"; + +const opts = [ + slashOptions.boolean({ name: "show", description: "To make the result visible to everyone", required: false }), +]; + +export const CaseSlashCmd = modActionsSlashCmd({ + 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 }), + + ...opts, + ], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: options.show !== true }); + actualCaseCmd(pluginData, interaction, interaction.user.id, options["case-number"], options.show); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/case/actualCaseCmd.ts b/backend/src/plugins/ModActions/commands/case/actualCaseCmd.ts new file mode 100644 index 00000000..ac4311ec --- /dev/null +++ b/backend/src/plugins/ModActions/commands/case/actualCaseCmd.ts @@ -0,0 +1,25 @@ +import { ChatInputCommandInteraction, Message } from "discord.js"; +import { GuildPluginData } from "knub"; +import { sendContextResponse } from "../../../../pluginUtils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualCaseCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + authorId: string, + caseNumber: number, + show: boolean | null, +) { + const theCase = await pluginData.state.cases.findByCaseNumber(caseNumber); + + if (!theCase) { + void pluginData.state.common.sendErrorMessage(context, "Case not found", undefined, undefined, show !== true); + return; + } + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const embed = await casesPlugin.getCaseEmbed(theCase.id, authorId); + + void sendContextResponse(context, { ...embed, ephemeral: show !== true }); +} 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..b57da947 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/cases/CasesModMsgCmd.ts @@ -0,0 +1,51 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualCasesCmd } from "./actualCasesCmd.js"; + +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" }), + kicks: ct.switchOption({ def: false, shortcut: "k" }), + bans: ct.switchOption({ def: false, shortcut: "b" }), + unbans: ct.switchOption({ def: false, shortcut: "ub" }), + show: ct.switchOption({ def: false, shortcut: "sh" }), +}; + +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, + args.mod, + null, + msg.member, + args.notes, + args.warns, + args.mutes, + args.unmutes, + args.kicks, + args.bans, + args.unbans, + args.reverseFilters, + args.hidden, + args.expand, + args.show, + ); + }, +}); 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..4a9d3e15 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/cases/CasesSlashCmd.ts @@ -0,0 +1,56 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { modActionsSlashCmd } from "../../types.js"; +import { actualCasesCmd } from "./actualCasesCmd.js"; + +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: "kicks", description: "To filter kicks", required: false }), + slashOptions.boolean({ name: "bans", description: "To filter bans", required: false }), + slashOptions.boolean({ name: "unbans", description: "To filter unbans", required: false }), + slashOptions.boolean({ name: "show", description: "To make the result visible to everyone", required: false }), +]; + +export const CasesSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: options.show !== true }); + + return actualCasesCmd( + pluginData, + interaction, + options.mod?.id ?? null, + options.user, + interaction.member as GuildMember, + options.notes, + options.warns, + options.mutes, + options.unmutes, + options.kicks, + options.bans, + options.unbans, + options["reverse-filters"], + options.hidden, + options.expand, + options.show, + ); + }, +}); 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..29adebd6 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/cases/CasesUserMsgCmd.ts @@ -0,0 +1,63 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { resolveMember, resolveUser, UnknownUser } from "../../../../utils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualCasesCmd } from "./actualCasesCmd.js"; + +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" }), + kicks: ct.switchOption({ def: false, shortcut: "k" }), + bans: ct.switchOption({ def: false, shortcut: "b" }), + unbans: ct.switchOption({ def: false, shortcut: "ub" }), + show: ct.switchOption({ def: false, shortcut: "sh" }), +}; + +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 resolveMember(pluginData.client, pluginData.guild, args.user)) || + (await resolveUser(pluginData.client, args.user)); + + if (user instanceof UnknownUser) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + return actualCasesCmd( + pluginData, + msg, + args.mod, + user, + msg.member, + args.notes, + args.warns, + args.mutes, + args.unmutes, + args.kicks, + args.bans, + args.unbans, + args.reverseFilters, + args.hidden, + args.expand, + args.show, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/cases/actualCasesCmd.ts b/backend/src/plugins/ModActions/commands/cases/actualCasesCmd.ts new file mode 100644 index 00000000..4ba1ecdd --- /dev/null +++ b/backend/src/plugins/ModActions/commands/cases/actualCasesCmd.ts @@ -0,0 +1,295 @@ +import { APIEmbed, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { In } from "typeorm"; +import { FindOptionsWhere } from "typeorm/find-options/FindOptionsWhere"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { Case } from "../../../../data/entities/Case.js"; +import { sendContextResponse } from "../../../../pluginUtils.js"; +import { + UnknownUser, + chunkArray, + emptyEmbedValue, + renderUsername, + resolveMember, + resolveUser, + trimLines, +} from "../../../../utils.js"; +import { asyncMap } from "../../../../utils/async.js"; +import { createPaginatedMessage } from "../../../../utils/createPaginatedMessage.js"; +import { getGuildPrefix } from "../../../../utils/getGuildPrefix.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { ModActionsPluginType } from "../../types.js"; + +const casesPerPage = 5; +const maxExpandedCases = 8; + +async function sendExpandedCases( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + casesCount: number, + cases: Case[], + show: boolean | null, +) { + if (casesCount > maxExpandedCases) { + await sendContextResponse(context, { + content: "Too many cases for expanded view. Please use compact view instead.", + ephemeral: true, + }); + + return; + } + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + + for (const theCase of cases) { + const embed = await casesPlugin.getCaseEmbed(theCase.id); + await sendContextResponse(context, { ...embed, ephemeral: !show }); + } +} + +async function casesUserCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + author: User, + modId: string | null, + user: GuildMember | User | UnknownUser, + modName: string, + typesToShow: CaseTypes[], + hidden: boolean | null, + expand: boolean | null, + show: 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 : renderUsername(user); + + if (cases.length === 0) { + await sendContextResponse(context, { + content: `No cases found for **${userName}**${modId ? ` by ${modName}` : ""}.`, + ephemeral: !show, + }); + + return; + } + + const casesToDisplay = hidden ? cases : normalCases; + + if (!casesToDisplay.length) { + await sendContextResponse(context, { + content: `No normal cases found for **${userName}**. Use "-hidden" to show ${cases.length} hidden cases.`, + ephemeral: !show, + }); + + return; + } + + if (expand) { + await sendExpandedCases(pluginData, context, casesToDisplay.length, casesToDisplay, show); + 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 UnknownUser ? undefined : user.displayAvatarURL(), + }, + description: linesInChunk.join("\n"), + fields: [...(isLastChunk ? [footerField] : [])], + } satisfies APIEmbed; + + await sendContextResponse(context, { embeds: [embed], ephemeral: !show }); + } +} + +async function casesModCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + author: User, + modId: string | null, + mod: GuildMember | User | UnknownUser, + modName: string, + typesToShow: CaseTypes[], + hidden: boolean | null, + expand: boolean | null, + show: boolean | null, +) { + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const casesFilters = { type: In(typesToShow), is_hidden: !!hidden }; + + const totalCases = await casesPlugin.getTotalCasesByMod(modId ?? author.id, casesFilters); + + if (totalCases === 0) { + pluginData.state.common.sendErrorMessage(context, `No cases by **${modName}**`, undefined, undefined, !show); + + 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, casesFilters); + + await sendExpandedCases(pluginData, context, totalCases, cases, show); + return; + } + + await createPaginatedMessage( + pluginData.client, + context, + totalPages, + async (page) => { + const cases = await casesPlugin.getRecentCasesByMod( + modId ?? author.id, + casesPerPage, + (page - 1) * casesPerPage, + casesFilters, + ); + + 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 UnknownUser ? undefined : mod.displayAvatarURL(), + }, + description: lines.join("\n"), + fields: [ + { + 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], ephemeral: !show }; + }, + { + limitToUserId: author.id, + }, + ); +} + +export async function actualCasesCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + modId: string | null, + user: GuildMember | User | UnknownUser | null, + author: GuildMember, + notes: boolean | null, + warns: boolean | null, + mutes: boolean | null, + unmutes: boolean | null, + kicks: boolean | null, + bans: boolean | null, + unbans: boolean | null, + reverseFilters: boolean | null, + hidden: boolean | null, + expand: boolean | null, + show: boolean | null, +) { + const mod = modId + ? (await resolveMember(pluginData.client, pluginData.guild, modId)) || (await resolveUser(pluginData.client, modId)) + : null; + const modName = modId ? (mod instanceof UnknownUser ? modId : renderUsername(mod!)) : renderUsername(author); + + const allTypes = [ + CaseTypes.Note, + CaseTypes.Warn, + CaseTypes.Mute, + CaseTypes.Unmute, + CaseTypes.Kick, + CaseTypes.Ban, + CaseTypes.Unban, + ]; + 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 (kicks) typesToShow.push(CaseTypes.Kick); + if (bans) typesToShow.push(CaseTypes.Ban); + if (unbans) typesToShow.push(CaseTypes.Unban); + + if (typesToShow.length === 0) { + typesToShow = allTypes; + } else { + if (reverseFilters) { + typesToShow = allTypes.filter((t) => !typesToShow.includes(t)); + } + } + + user + ? await casesUserCmd( + pluginData, + context, + author.user, + modId!, + user, + modName, + typesToShow, + hidden, + expand, + show === true, + ) + : await casesModCmd( + pluginData, + context, + author.user, + modId!, + mod ?? author, + modName, + typesToShow, + hidden, + expand, + show === true, + ); +} 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..c1449e93 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseMsgCmd.ts @@ -0,0 +1,23 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { trimLines } from "../../../../utils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualDeleteCaseCmd } from "./actualDeleteCaseCmd.js"; + +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, 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..b324c6e3 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseSlashCmd.ts @@ -0,0 +1,31 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { modActionsSlashCmd } from "../../types.js"; +import { actualDeleteCaseCmd } from "./actualDeleteCaseCmd.js"; + +const opts = [slashOptions.boolean({ name: "force", description: "Whether or not to force delete", required: false })]; + +export const DeleteCaseSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: true }); + + actualDeleteCaseCmd( + pluginData, + interaction, + interaction.member as GuildMember, + options["case-number"].split(/\D+/).map(Number), + !!options.force, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/deletecase/actualDeleteCaseCmd.ts b/backend/src/plugins/ModActions/commands/deletecase/actualDeleteCaseCmd.ts new file mode 100644 index 00000000..0e57fbf1 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/deletecase/actualDeleteCaseCmd.ts @@ -0,0 +1,93 @@ +import { ChatInputCommandInteraction, GuildMember, Message } from "discord.js"; +import { GuildPluginData, helpers } from "knub"; +import { Case } from "../../../../data/entities/Case.js"; +import { getContextChannel, sendContextResponse } from "../../../../pluginUtils.js"; +import { SECONDS, renderUsername } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { TimeAndDatePlugin } from "../../../TimeAndDate/TimeAndDatePlugin.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualDeleteCaseCmd( + pluginData: GuildPluginData, + context: Message | 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) { + pluginData.state.common.sendErrorMessage(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, + await getContextChannel(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 = renderUsername(author); + + 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) { + pluginData.state.common.sendErrorMessage(context, "All deletions were cancelled, no cases were deleted."); + return; + } + + pluginData.state.common.sendSuccessMessage( + context, + `${amt} case${amt === 1 ? " was" : "s were"} deleted!${failedAddendum}`, + ); +} 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..2f2cce21 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forceban/ForceBanMsgCmd.ts @@ -0,0 +1,60 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { canActOn, hasPermission } from "../../../../pluginUtils.js"; +import { resolveMember, resolveUser } from "../../../../utils.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualForceBanCmd } from "./actualForceBanCmd.js"; + +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) { + pluginData.state.common.sendErrorMessage(msg, `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)) { + pluginData.state.common.sendErrorMessage(msg, "Cannot forceban this user: insufficient permissions"); + return; + } + + // Make sure the user isn't already banned + const banned = await isBanned(pluginData, user.id); + if (banned) { + pluginData.state.common.sendErrorMessage(msg, `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 }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + } + + actualForceBanCmd(pluginData, msg, 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..61fc9411 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forceban/ForceBanSlashCmd.ts @@ -0,0 +1,68 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { convertDelayStringToMS, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualForceBanCmd } from "./actualForceBanCmd.js"; + +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", + }), +]; + +export const ForceBanSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member as GuildMember; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) : null; + if (options.time && !convertedTime) { + pluginData.state.common.sendErrorMessage(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/forceban/actualForceBanCmd.ts b/backend/src/plugins/ModActions/commands/forceban/actualForceBanCmd.ts new file mode 100644 index 00000000..63495c62 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forceban/actualForceBanCmd.ts @@ -0,0 +1,68 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { LogType } from "../../../../data/LogType.js"; +import { DAYS, MINUTES, UnknownUser } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { ignoreEvent } from "../../functions/ignoreEvent.js"; +import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; + +export async function actualForceBanCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + authorId: string, + user: User | UnknownUser, + reason: string, + attachments: Array, + mod: GuildMember, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + const formattedReasonWithAttachments = 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: formattedReasonWithAttachments ?? undefined, + }); + } catch { + pluginData.state.common.sendErrorMessage(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 + pluginData.state.common.sendSuccessMessage(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/commands/forcemute/ForceMuteMsgCmd.ts b/backend/src/plugins/ModActions/commands/forcemute/ForceMuteMsgCmd.ts new file mode 100644 index 00000000..1268fbc4 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forcemute/ForceMuteMsgCmd.ts @@ -0,0 +1,84 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { canActOn, hasPermission } from "../../../../pluginUtils.js"; +import { resolveMember, resolveUser } from "../../../../utils.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualMuteCmd } from "../mute/actualMuteCmd.js"; + +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) { + pluginData.state.common.sendErrorMessage(msg, `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)) { + pluginData.state.common.sendErrorMessage(msg, "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 }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + ppId = msg.author.id; + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + pluginData.state.common.sendErrorMessage(msg, e.message); + return; + } + + actualMuteCmd( + pluginData, + msg, + 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..fabdd2ab --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forcemute/ForceMuteSlashCmd.ts @@ -0,0 +1,97 @@ +import { ChannelType, GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { UserNotificationMethod, convertDelayStringToMS, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualMuteCmd } from "../mute/actualMuteCmd.js"; + +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", + }), +]; + +export const ForceMuteSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member as GuildMember; + let ppId: string | undefined; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + ppId = interaction.user.id; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) ?? undefined : undefined; + if (options.time && !convertedTime) { + pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); + return; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + pluginData.state.common.sendErrorMessage(interaction, e.message); + return; + } + + actualMuteCmd( + pluginData, + interaction, + options.user, + attachments, + mod, + ppId, + convertedTime, + options.reason ?? "", + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteMsgCmd.ts b/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteMsgCmd.ts new file mode 100644 index 00000000..6194a932 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteMsgCmd.ts @@ -0,0 +1,79 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { canActOn, hasPermission } from "../../../../pluginUtils.js"; +import { resolveMember, resolveUser } from "../../../../utils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualUnmuteCmd } from "../unmute/actualUnmuteCmd.js"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const ForceUnmuteMsgCmd = modActionsMsgCmd({ + trigger: "forceunmute", + permission: "can_mute", + description: "Force-unmute the specified user, even if they're not on the server", + + signature: [ + { + user: ct.string(), + time: ct.delay(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + // Check if they're muted in the first place + if (!(await pluginData.state.mutes.isMuted(user.id))) { + pluginData.state.common.sendErrorMessage(msg, "Cannot unmute: member is not muted"); + return; + } + + // Find the server member to unmute + const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id); + + // Make sure we're allowed to unmute this member + if (memberToUnmute && !canActOn(pluginData, msg.member, memberToUnmute)) { + pluginData.state.common.sendErrorMessage(msg, "Cannot unmute: 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 }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + ppId = msg.author.id; + } + + actualUnmuteCmd( + pluginData, + msg, + 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..1fcd135c --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteSlashCmd.ts @@ -0,0 +1,63 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { convertDelayStringToMS, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualUnmuteCmd } from "../unmute/actualUnmuteCmd.js"; + +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", + }), +]; + +export const ForceUnmuteSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member as GuildMember; + let ppId: string | undefined; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + ppId = interaction.user.id; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) ?? undefined : undefined; + if (options.time && !convertedTime) { + pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); + return; + } + + actualUnmuteCmd(pluginData, interaction, options.user, attachments, mod, ppId, convertedTime, 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..ef5c7e7a --- /dev/null +++ b/backend/src/plugins/ModActions/commands/hidecase/HideCaseMsgCmd.ts @@ -0,0 +1,19 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualHideCaseCmd } from "./actualHideCaseCmd.js"; + +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, 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..114120dc --- /dev/null +++ b/backend/src/plugins/ModActions/commands/hidecase/HideCaseSlashCmd.ts @@ -0,0 +1,19 @@ +import { slashOptions } from "knub"; +import { modActionsSlashCmd } from "../../types.js"; +import { actualHideCaseCmd } from "./actualHideCaseCmd.js"; + +export const HideCaseSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: true }); + actualHideCaseCmd(pluginData, interaction, options["case-number"].split(/\D+/).map(Number)); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/hidecase/actualHideCaseCmd.ts b/backend/src/plugins/ModActions/commands/hidecase/actualHideCaseCmd.ts new file mode 100644 index 00000000..1c33efc1 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/hidecase/actualHideCaseCmd.ts @@ -0,0 +1,36 @@ +import { ChatInputCommandInteraction, Message } from "discord.js"; +import { GuildPluginData } from "knub"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualHideCaseCmd( + pluginData: GuildPluginData, + context: Message | 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) { + pluginData.state.common.sendErrorMessage(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; + pluginData.state.common.sendSuccessMessage( + context, + `${amt} case${amt === 1 ? " is" : "s are"} now hidden! Use \`unhidecase\` to unhide them.${failedAddendum}`, + ); +} 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..ebc165ff --- /dev/null +++ b/backend/src/plugins/ModActions/commands/kick/KickMsgCmd.ts @@ -0,0 +1,67 @@ +import { hasPermission } from "knub/helpers"; +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { resolveUser } from "../../../../utils.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualKickCmd } from "./actualKickCmd.js"; + +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) { + pluginData.state.common.sendErrorMessage(msg, `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"))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + pluginData.state.common.sendErrorMessage(msg, e.message); + return; + } + + actualKickCmd( + pluginData, + msg, + 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..a020fb5e --- /dev/null +++ b/backend/src/plugins/ModActions/commands/kick/KickSlashCmd.ts @@ -0,0 +1,93 @@ +import { ChannelType, GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { UserNotificationMethod, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualKickCmd } from "./actualKickCmd.js"; + +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", + }), +]; + +export const KickSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member as GuildMember; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + pluginData.state.common.sendErrorMessage(interaction, e.message); + return; + } + + actualKickCmd( + pluginData, + interaction, + interaction.member as GuildMember, + options.user, + options.reason || "", + attachments, + mod, + contactMethods, + options.clean, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/kick/actualKickCmd.ts b/backend/src/plugins/ModActions/commands/kick/actualKickCmd.ts new file mode 100644 index 00000000..08bf6c6c --- /dev/null +++ b/backend/src/plugins/ModActions/commands/kick/actualKickCmd.ts @@ -0,0 +1,91 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { LogType } from "../../../../data/LogType.js"; +import { canActOn } from "../../../../pluginUtils.js"; +import { DAYS, SECONDS, UnknownUser, UserNotificationMethod, renderUsername, resolveMember } from "../../../../utils.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { ignoreEvent } from "../../functions/ignoreEvent.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { kickMember } from "../../functions/kickMember.js"; +import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; + +export async function actualKickCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + author: GuildMember, + user: User | UnknownUser, + reason: string, + attachments: Attachment[], + mod: GuildMember, + contactMethods?: UserNotificationMethod[], + clean?: boolean | null, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const memberToKick = await resolveMember(pluginData.client, pluginData.guild, user.id); + + if (!memberToKick) { + const banned = await isBanned(pluginData, user.id); + if (banned) { + pluginData.state.common.sendErrorMessage(context, `User is banned`); + } else { + pluginData.state.common.sendErrorMessage(context, `User not found on the server`); + } + + return; + } + + // Make sure we're allowed to kick this member + if (!canActOn(pluginData, author, memberToKick)) { + pluginData.state.common.sendErrorMessage(context, "Cannot kick: insufficient permissions"); + return; + } + + const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + const formattedReasonWithAttachments = formatReasonWithAttachments(reason, attachments); + + const kickResult = await kickMember(pluginData, memberToKick, formattedReason, formattedReasonWithAttachments, { + 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 { + pluginData.state.common.sendErrorMessage(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 { + pluginData.state.common.sendErrorMessage(context, "Failed to unban the user after banning them (-clean)"); + } + } + + if (kickResult.status === "failed") { + pluginData.state.common.sendErrorMessage(context, `Failed to kick user`); + return; + } + + // Confirm the action to the moderator + let response = `Kicked **${renderUsername(memberToKick.user)}** (Case #${kickResult.case.case_number})`; + + if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`; + pluginData.state.common.sendSuccessMessage(context, response); +} 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..ac5fdb4f --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massban/MassBanMsgCmd.ts @@ -0,0 +1,32 @@ +import { waitForReply } from "knub/helpers"; +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { getContextChannel, sendContextResponse } from "../../../../pluginUtils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualMassBanCmd } from "./actualMassBanCmd.js"; + +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 }) { + // Ask for ban reason (cleaner this way instead of trying to cram it into the args) + sendContextResponse(msg, "Ban reason? `cancel` to cancel"); + const banReasonReply = await waitForReply(pluginData.client, await getContextChannel(msg), msg.author.id); + + if (!banReasonReply || !banReasonReply.content || banReasonReply.content.toLowerCase().trim() === "cancel") { + pluginData.state.common.sendErrorMessage(msg, "Cancelled"); + return; + } + + actualMassBanCmd(pluginData, msg, args.userIds, msg.member, banReasonReply.content, [ + ...banReasonReply.attachments.values(), + ]); + }, +}); 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..df89a726 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massban/MassBanSlashCmd.ts @@ -0,0 +1,47 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualMassBanCmd } from "./actualMassBanCmd.js"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const MassBanSlashCmd = modActionsSlashCmd({ + 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 }), + + ...opts, + ], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + actualMassBanCmd( + pluginData, + interaction, + options["user-ids"].split(/\D+/), + interaction.member as GuildMember, + options.reason || "", + attachments, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/massban/actualMassBanCmd.ts b/backend/src/plugins/ModActions/commands/massban/actualMassBanCmd.ts new file mode 100644 index 00000000..8ced00c4 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massban/actualMassBanCmd.ts @@ -0,0 +1,173 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { LogType } from "../../../../data/LogType.js"; +import { humanizeDurationShort } from "../../../../humanizeDurationShort.js"; +import { canActOn, getContextChannel, isContextInteraction, sendContextResponse } from "../../../../pluginUtils.js"; +import { DAYS, MINUTES, SECONDS, noop } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { ignoreEvent } from "../../functions/ignoreEvent.js"; +import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; + +export async function actualMassBanCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + userIds: string[], + author: GuildMember, + reason: string, + attachments: Attachment[], +) { + // Limit to 100 users at once (arbitrary?) + if (userIds.length > 100) { + pluginData.state.common.sendErrorMessage(context, `Can only massban max 100 users at once`); + return; + } + + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const banReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + const banReasonWithAttachments = formatReasonWithAttachments(reason, attachments); + + // 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)) { + pluginData.state.common.sendErrorMessage(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, { content: initialLoadingText, ephemeral: true }); + + const waitTimeStart = performance.now(); + const waitingInterval = setInterval(() => { + const waitTime = humanizeDurationShort(performance.now() - waitTimeStart, { round: true }); + const waitMessageContent = `Massban queued. Still waiting for previous massban to finish (waited ${waitTime}).`; + + if (isContextInteraction(context)) { + context.editReply(waitMessageContent).catch(() => clearInterval(waitingInterval)); + } else { + loadingMsg.edit(waitMessageContent).catch(() => clearInterval(waitingInterval)); + } + }, 1 * MINUTES); + + pluginData.state.massbanQueue.add(async () => { + clearInterval(waitingInterval); + + if (pluginData.state.unloaded) { + if (isContextInteraction(context)) { + void context.deleteReply().catch(noop); + } else { + void loadingMsg.delete().catch(noop); + } + + return; + } + + if (isContextInteraction(context)) { + void context.editReply("Banning...").catch(noop); + } else { + 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(await getContextChannel(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: banReasonWithAttachments, + }); + + 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) { + const newLoadingMessageContent = `Banning... ${i + 1}/${userIds.length}`; + + if (isContextInteraction(context)) { + void context.editReply(newLoadingMessageContent).catch(noop); + } else { + loadingMsg.edit(newLoadingMessageContent).catch(noop); + } + } + } + + const totalTime = performance.now() - startTime; + const formattedTimeTaken = humanizeDurationShort(totalTime, { round: true }); + + if (!isContextInteraction(context)) { + // 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 + pluginData.state.common.sendErrorMessage(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) { + pluginData.state.common.sendSuccessMessage( + context, + `Banned ${successfulBanCount} users in ${formattedTimeTaken}, ${failedBans.length} failed: ${failedBans.join( + " ", + )}`, + ); + } else { + pluginData.state.common.sendSuccessMessage( + context, + `Banned ${successfulBanCount} users successfully in ${formattedTimeTaken}`, + ); + } + } + }); +} 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..1c9c37ae --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massmute/MassMuteMsgCmd.ts @@ -0,0 +1,35 @@ +import { waitForReply } from "knub/helpers"; +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { getContextChannel, sendContextResponse } from "../../../../pluginUtils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualMassMuteCmd } from "./actualMassMuteCmd.js"; + +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 }) { + // Ask for mute reason + sendContextResponse(msg, "Mute reason? `cancel` to cancel"); + const muteReasonReceived = await waitForReply(pluginData.client, await getContextChannel(msg), msg.author.id); + if ( + !muteReasonReceived || + !muteReasonReceived.content || + muteReasonReceived.content.toLowerCase().trim() === "cancel" + ) { + pluginData.state.common.sendErrorMessage(msg, "Cancelled"); + return; + } + + actualMassMuteCmd(pluginData, msg, args.userIds, msg.member, muteReasonReceived.content, [ + ...muteReasonReceived.attachments.values(), + ]); + }, +}); 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..eabab03e --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massmute/MassMuteSlashCmd.ts @@ -0,0 +1,47 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualMassMuteCmd } from "./actualMassMuteCmd.js"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const MassMuteSlashSlashCmd = modActionsSlashCmd({ + 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 }), + + ...opts, + ], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + actualMassMuteCmd( + pluginData, + interaction, + options["user-ids"].split(/\D+/), + interaction.member as GuildMember, + options.reason || "", + attachments, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/massmute/actualMassMuteCmd.ts b/backend/src/plugins/ModActions/commands/massmute/actualMassMuteCmd.ts new file mode 100644 index 00000000..f0c8053c --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massmute/actualMassMuteCmd.ts @@ -0,0 +1,97 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake } from "discord.js"; +import { GuildPluginData } from "knub"; +import { LogType } from "../../../../data/LogType.js"; +import { logger } from "../../../../logger.js"; +import { canActOn, isContextInteraction, sendContextResponse } from "../../../../pluginUtils.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualMassMuteCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + userIds: string[], + author: GuildMember, + reason: string, + attachments: Attachment[], +) { + // Limit to 100 users at once (arbitrary?) + if (userIds.length > 100) { + pluginData.state.common.sendErrorMessage(context, `Can only massmute max 100 users at once`); + return; + } + + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const muteReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + const muteReasonWithAttachments = formatReasonWithAttachments(reason, attachments); + + // 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)) { + pluginData.state.common.sendErrorMessage(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, { content: "Muting...", ephemeral: true }); + + // 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}`, `Mass mute: ${muteReasonWithAttachments}`, { + caseArgs: { + modId, + }, + }); + } catch (e) { + logger.info(e); + failedMutes.push(userId); + } + } + + if (!isContextInteraction(context)) { + // Clear loading indicator + loadingMsg.delete(); + } + + const successfulMuteCount = userIds.length - failedMutes.length; + if (successfulMuteCount === 0) { + // All mutes failed + pluginData.state.common.sendErrorMessage(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) { + pluginData.state.common.sendSuccessMessage( + context, + `Muted ${successfulMuteCount} users, ${failedMutes.length} failed: ${failedMutes.join(" ")}`, + ); + } else { + pluginData.state.common.sendSuccessMessage(context, `Muted ${successfulMuteCount} users successfully`); + } + } +} 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..6bfa1f18 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massunban/MassUnbanMsgCmd.ts @@ -0,0 +1,31 @@ +import { waitForReply } from "knub/helpers"; +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { getContextChannel, sendContextResponse } from "../../../../pluginUtils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualMassUnbanCmd } from "./actualMassUnbanCmd.js"; + +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 }) { + // Ask for unban reason (cleaner this way instead of trying to cram it into the args) + sendContextResponse(msg, "Unban reason? `cancel` to cancel"); + const unbanReasonReply = await waitForReply(pluginData.client, await getContextChannel(msg), msg.author.id); + if (!unbanReasonReply || !unbanReasonReply.content || unbanReasonReply.content.toLowerCase().trim() === "cancel") { + pluginData.state.common.sendErrorMessage(msg, "Cancelled"); + return; + } + + actualMassUnbanCmd(pluginData, msg, args.userIds, msg.member, unbanReasonReply.content, [ + ...unbanReasonReply.attachments.values(), + ]); + }, +}); 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..57e69fad --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massunban/MassUnbanSlashCmd.ts @@ -0,0 +1,47 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualMassUnbanCmd } from "./actualMassUnbanCmd.js"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const MassUnbanSlashCmd = modActionsSlashCmd({ + 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 }), + + ...opts, + ], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + actualMassUnbanCmd( + pluginData, + interaction, + options["user-ids"].split(/[\s,\r\n]+/), + interaction.member as GuildMember, + options.reason || "", + attachments, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/massunban/actualMassUnbanCmd.ts b/backend/src/plugins/ModActions/commands/massunban/actualMassUnbanCmd.ts new file mode 100644 index 00000000..ce973a05 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massunban/actualMassUnbanCmd.ts @@ -0,0 +1,118 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { LogType } from "../../../../data/LogType.js"; +import { isContextInteraction, sendContextResponse } from "../../../../pluginUtils.js"; +import { MINUTES, noop } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; +import { ignoreEvent } from "../../functions/ignoreEvent.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; + +export async function actualMassUnbanCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + userIds: string[], + author: GuildMember, + reason: string, + attachments: Attachment[], +) { + // Limit to 100 users at once (arbitrary?) + if (userIds.length > 100) { + pluginData.state.common.sendErrorMessage(context, `Can only mass-unban max 100 users at once`); + return; + } + + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const unbanReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + + // 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, 2 * MINUTES); + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, userId, 2 * MINUTES); + }); + + // Show a loading indicator since this can take a while + const loadingMsg = await sendContextResponse(context, { content: "Unbanning...", ephemeral: true }); + + // 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 }); + } + } + + if (!isContextInteraction(context)) { + // Clear loading indicator + loadingMsg.delete().catch(noop); + } + + const successfulUnbanCount = userIds.length - failedUnbans.length; + if (successfulUnbanCount === 0) { + // All unbans failed - don't create a log entry and notify the user + pluginData.state.common.sendErrorMessage(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; + }); + } + + pluginData.state.common.sendSuccessMessage( + context, + `Unbanned ${successfulUnbanCount} users, ${failedUnbans.length} failed:\n${failedMsg}`, + ); + } else { + pluginData.state.common.sendSuccessMessage(context, `Unbanned ${successfulUnbanCount} users successfully`); + } + } +} + +enum UnbanFailReasons { + NOT_BANNED = "Not banned", + UNBAN_FAILED = "Unban failed", +} diff --git a/backend/src/plugins/ModActions/commands/mute/MuteMsgCmd.ts b/backend/src/plugins/ModActions/commands/mute/MuteMsgCmd.ts new file mode 100644 index 00000000..d31efb0c --- /dev/null +++ b/backend/src/plugins/ModActions/commands/mute/MuteMsgCmd.ts @@ -0,0 +1,110 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { canActOn, hasPermission } from "../../../../pluginUtils.js"; +import { resolveMember, resolveUser } from "../../../../utils.js"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualMuteCmd } from "./actualMuteCmd.js"; + +const opts = { + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), +}; + +export const MuteMsgCmd = modActionsMsgCmd({ + trigger: "mute", + permission: "can_mute", + description: "Mute 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) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); + + if (!memberToMute) { + const _isBanned = await isBanned(pluginData, user.id); + const prefix = pluginData.fullConfig.prefix; + if (_isBanned) { + pluginData.state.common.sendErrorMessage( + msg, + `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( + msg, + { content: "User not found on the server, forcemute instead?" }, + { confirmText: "Yes", cancelText: "No", restrictToId: msg.member.id }, + ); + + if (!reply) { + pluginData.state.common.sendErrorMessage(msg, "User not on server, mute cancelled by moderator"); + return; + } + } + } + + // Make sure we're allowed to mute this member + if (memberToMute && !canActOn(pluginData, msg.member, memberToMute)) { + pluginData.state.common.sendErrorMessage(msg, "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 }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + ppId = msg.author.id; + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + pluginData.state.common.sendErrorMessage(msg, e.message); + return; + } + + actualMuteCmd( + pluginData, + msg, + 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..a8378f76 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/mute/MuteSlashCmd.ts @@ -0,0 +1,124 @@ +import { ChannelType, GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { canActOn, hasPermission } from "../../../../pluginUtils.js"; +import { UserNotificationMethod, convertDelayStringToMS, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualMuteCmd } from "./actualMuteCmd.js"; + +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", + }), +]; + +export const MuteSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + 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) { + pluginData.state.common.sendErrorMessage( + 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.user.id }, + ); + + if (!reply) { + pluginData.state.common.sendErrorMessage(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 as GuildMember, memberToMute)) { + pluginData.state.common.sendErrorMessage(interaction, "Cannot mute: insufficient permissions"); + return; + } + + let mod = interaction.member as GuildMember; + let ppId: string | undefined; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + ppId = interaction.user.id; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) ?? undefined : undefined; + if (options.time && !convertedTime) { + pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); + return; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + pluginData.state.common.sendErrorMessage(interaction, e.message); + return; + } + + actualMuteCmd( + pluginData, + interaction, + options.user, + attachments, + mod, + ppId, + convertedTime, + options.reason, + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/mute/actualMuteCmd.ts b/backend/src/plugins/ModActions/commands/mute/actualMuteCmd.ts new file mode 100644 index 00000000..2b30ecd9 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/mute/actualMuteCmd.ts @@ -0,0 +1,108 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; +import humanizeDuration from "humanize-duration"; +import { GuildPluginData } from "knub"; +import { ERRORS, RecoverablePluginError } from "../../../../RecoverablePluginError.js"; +import { logger } from "../../../../logger.js"; +import { + UnknownUser, + UserNotificationMethod, + asSingleLine, + isDiscordAPIError, + renderUsername, +} from "../../../../utils.js"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; +import { MuteResult } from "../../../Mutes/types.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { ModActionsPluginType } from "../../types.js"; + +/** + * 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: Message | ChatInputCommandInteraction, + user: User | UnknownUser, + attachments: Attachment[], + mod: GuildMember, + ppId?: string, + time?: number, + reason?: string | null, + contactMethods?: UserNotificationMethod[], +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const timeUntilUnmute = time && humanizeDuration(time); + const formattedReason = + reason || attachments.length > 0 + ? await formatReasonWithMessageLinkForAttachments(pluginData, reason ?? "", context, attachments) + : undefined; + const formattedReasonWithAttachments = + reason || attachments.length > 0 ? formatReasonWithAttachments(reason ?? "", attachments) : undefined; + + let muteResult: MuteResult; + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + + try { + muteResult = await mutesPlugin.muteUser(user.id, time, formattedReason, formattedReasonWithAttachments, { + contactMethods, + caseArgs: { + modId: mod.id, + ppId, + }, + }); + } catch (e) { + if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { + pluginData.state.common.sendErrorMessage(context, "Could not mute the user: no mute role set in config"); + } else if (isDiscordAPIError(e) && e.code === 10007) { + pluginData.state.common.sendErrorMessage(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"); + } + pluginData.state.common.sendErrorMessage(context, "Could not mute the user"); + } + + return; + } + + // Confirm the action to the moderator + let response: string; + if (time) { + if (muteResult.updatedExistingMute) { + response = asSingleLine(` + Updated **${renderUsername(user)}**'s + mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number}) + `); + } else { + response = asSingleLine(` + Muted **${renderUsername(user)}** + for ${timeUntilUnmute} (Case #${muteResult.case.case_number}) + `); + } + } else { + if (muteResult.updatedExistingMute) { + response = asSingleLine(` + Updated **${renderUsername(user)}**'s + mute to indefinite (Case #${muteResult.case.case_number}) + `); + } else { + response = asSingleLine(` + Muted **${renderUsername(user)}** + indefinitely (Case #${muteResult.case.case_number}) + `); + } + } + + if (muteResult.notifyResult.text) response += ` (${muteResult.notifyResult.text})`; + pluginData.state.common.sendSuccessMessage(context, response); +} diff --git a/backend/src/plugins/ModActions/commands/note/NoteMsgCmd.ts b/backend/src/plugins/ModActions/commands/note/NoteMsgCmd.ts new file mode 100644 index 00000000..9049f981 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/note/NoteMsgCmd.ts @@ -0,0 +1,30 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { resolveUser } from "../../../../utils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualNoteCmd } from "./actualNoteCmd.js"; + +export const NoteMsgCmd = modActionsMsgCmd({ + trigger: "note", + permission: "can_note", + description: "Add a note to the specified user", + + signature: { + user: ct.string(), + note: ct.string({ required: false, catchAll: true }), + }, + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + if (!args.note && msg.attachments.size === 0) { + pluginData.state.common.sendErrorMessage(msg, "Text or attachment required"); + return; + } + + actualNoteCmd(pluginData, msg, msg.author, [...msg.attachments.values()], user, args.note || ""); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/note/NoteSlashCmd.ts b/backend/src/plugins/ModActions/commands/note/NoteSlashCmd.ts new file mode 100644 index 00000000..507df72a --- /dev/null +++ b/backend/src/plugins/ModActions/commands/note/NoteSlashCmd.ts @@ -0,0 +1,35 @@ +import { slashOptions } from "knub"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualNoteCmd } from "./actualNoteCmd.js"; + +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 = modActionsSlashCmd({ + name: "note", + configPermission: "can_note", + description: "Add a note to the specified user", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to add a note to", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.note || options.note.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + actualNoteCmd(pluginData, interaction, interaction.user, attachments, options.user, options.note || ""); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/note/actualNoteCmd.ts b/backend/src/plugins/ModActions/commands/note/actualNoteCmd.ts new file mode 100644 index 00000000..8914a524 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/note/actualNoteCmd.ts @@ -0,0 +1,50 @@ +import { Attachment, ChatInputCommandInteraction, Message, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { UnknownUser, renderUsername } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualNoteCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + author: User, + attachments: Array, + user: User | UnknownUser, + note: string, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, note)) { + return; + } + + const userName = renderUsername(user); + const reason = await formatReasonWithMessageLinkForAttachments(pluginData, note, context, attachments); + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: user.id, + modId: author.id, + type: CaseTypes.Note, + reason, + }); + + pluginData.getPlugin(LogsPlugin).logMemberNote({ + mod: author, + user, + caseNumber: createdCase.case_number, + reason, + }); + + pluginData.state.common.sendSuccessMessage( + context, + `Note added on **${userName}** (Case #${createdCase.case_number})`, + undefined, + undefined, + true, + ); + + pluginData.state.events.emit("note", user.id, reason); +} 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..8cf5d499 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unban/UnbanMsgCmd.ts @@ -0,0 +1,45 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { resolveUser } from "../../../../utils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualUnbanCmd } from "./actualUnbanCmd.js"; + +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) { + pluginData.state.common.sendErrorMessage(msg, `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 }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + } + + actualUnbanCmd(pluginData, msg, 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..7c910e53 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unban/UnbanSlashCmd.ts @@ -0,0 +1,54 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualUnbanCmd } from "./actualUnbanCmd.js"; + +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", + }), +]; + +export const UnbanSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member as GuildMember; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + } + + actualUnbanCmd(pluginData, interaction, interaction.user.id, options.user, options.reason ?? "", attachments, mod); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/unban/actualUnbanCmd.ts b/backend/src/plugins/ModActions/commands/unban/actualUnbanCmd.ts new file mode 100644 index 00000000..e984a944 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unban/actualUnbanCmd.ts @@ -0,0 +1,67 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { LogType } from "../../../../data/LogType.js"; +import { clearExpiringTempban } from "../../../../data/loops/expiringTempbansLoop.js"; +import { UnknownUser } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; +import { ignoreEvent } from "../../functions/ignoreEvent.js"; +import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; + +export async function actualUnbanCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + authorId: string, + user: User | UnknownUser, + reason: string, + attachments: Array, + mod: GuildMember, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, user.id); + const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + + try { + ignoreEvent(pluginData, IgnoredEventType.Unban, user.id); + await pluginData.guild.bans.remove(user.id as Snowflake, formattedReason ?? undefined); + } catch { + pluginData.state.common.sendErrorMessage(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 + pluginData.state.common.sendSuccessMessage(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/commands/unhidecase/UnhideCaseMsgCmd.ts b/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseMsgCmd.ts new file mode 100644 index 00000000..1fddfeef --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseMsgCmd.ts @@ -0,0 +1,19 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualHideCaseCmd } from "../hidecase/actualHideCaseCmd.js"; + +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, 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..1b29fac6 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseSlashCmd.ts @@ -0,0 +1,19 @@ +import { slashOptions } from "knub"; +import { modActionsSlashCmd } from "../../types.js"; +import { actualUnhideCaseCmd } from "./actualUnhideCaseCmd.js"; + +export const UnhideCaseSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: true }); + actualUnhideCaseCmd(pluginData, interaction, options["case-number"].split(/\D+/).map(Number)); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/unhidecase/actualUnhideCaseCmd.ts b/backend/src/plugins/ModActions/commands/unhidecase/actualUnhideCaseCmd.ts new file mode 100644 index 00000000..d270107a --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unhidecase/actualUnhideCaseCmd.ts @@ -0,0 +1,37 @@ +import { ChatInputCommandInteraction, Message } from "discord.js"; +import { GuildPluginData } from "knub"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualUnhideCaseCmd( + pluginData: GuildPluginData, + context: Message | 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) { + pluginData.state.common.sendErrorMessage(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; + pluginData.state.common.sendSuccessMessage( + context, + `${amt} case${amt === 1 ? " is" : "s are"} no longer hidden!${failedAddendum}`, + ); +} diff --git a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts b/backend/src/plugins/ModActions/commands/unmute/UnmuteMsgCmd.ts similarity index 52% rename from backend/src/plugins/ModActions/commands/UnmuteCmd.ts rename to backend/src/plugins/ModActions/commands/unmute/UnmuteMsgCmd.ts index 06c50003..638ef2f1 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.js"; -import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin.js"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils.js"; -import { resolveMember, resolveUser } from "../../../utils.js"; -import { waitForButtonConfirm } from "../../../utils/waitForInteraction.js"; -import { actualUnmuteCmd } from "../functions/actualUnmuteUserCmd.js"; -import { isBanned } from "../functions/isBanned.js"; -import { modActionsCmd } from "../types.js"; +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { canActOn, hasPermission } from "../../../../pluginUtils.js"; +import { resolveMember, resolveUser } from "../../../../utils.js"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualUnmuteCmd } from "./actualUnmuteCmd.js"; const opts = { mod: ct.member({ option: true }), }; -export const UnmuteCmd = modActionsCmd({ +export const UnmuteMsgCmd = modActionsMsgCmd({ trigger: "unmute", permission: "can_mute", description: "Unmute the specified member", @@ -35,7 +35,7 @@ export const UnmuteCmd = modActionsCmd({ async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user); if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); + pluginData.state.common.sendErrorMessage(msg, `User not found`); return; } @@ -49,7 +49,7 @@ export const UnmuteCmd = modActionsCmd({ !hasMuteRole && !memberToUnmute?.isCommunicationDisabled() ) { - sendErrorMessage(pluginData, msg.channel, "Cannot unmute: member is not muted"); + pluginData.state.common.sendErrorMessage(msg, "Cannot unmute: member is not muted"); return; } @@ -57,22 +57,21 @@ export const UnmuteCmd = modActionsCmd({ const banned = await isBanned(pluginData, user.id); const prefix = pluginData.fullConfig.prefix; if (banned) { - sendErrorMessage( - pluginData, - msg.channel, + pluginData.state.common.sendErrorMessage( + msg, `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( - msg.channel, + msg, { content: "User not on server, forceunmute instead?" }, { confirmText: "Yes", cancelText: "No", restrictToId: msg.member.id }, ); if (!reply) { - sendErrorMessage(pluginData, msg.channel, "User not on server, unmute cancelled by moderator"); + pluginData.state.common.sendErrorMessage(msg, "User not on server, unmute cancelled by moderator"); return; } } @@ -80,10 +79,33 @@ export const UnmuteCmd = modActionsCmd({ // Make sure we're allowed to unmute this member if (memberToUnmute && !canActOn(pluginData, msg.member, memberToUnmute)) { - sendErrorMessage(pluginData, msg.channel, "Cannot unmute: insufficient permissions"); + pluginData.state.common.sendErrorMessage(msg, "Cannot unmute: insufficient permissions"); 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 }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + ppId = msg.author.id; + } + + actualUnmuteCmd( + pluginData, + msg, + 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..6d3be623 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unmute/UnmuteSlashCmd.ts @@ -0,0 +1,110 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { canActOn, hasPermission } from "../../../../pluginUtils.js"; +import { convertDelayStringToMS, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualUnmuteCmd } from "./actualUnmuteCmd.js"; + +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", + }), +]; + +export const UnmuteSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(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() + ) { + pluginData.state.common.sendErrorMessage(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) { + pluginData.state.common.sendErrorMessage( + 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) { + pluginData.state.common.sendErrorMessage(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 as GuildMember, memberToUnmute)) { + pluginData.state.common.sendErrorMessage(interaction, "Cannot unmute: insufficient permissions"); + return; + } + + let mod = interaction.member as GuildMember; + let ppId: string | undefined; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + ppId = interaction.user.id; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) ?? undefined : undefined; + if (options.time && !convertedTime) { + pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); + return; + } + + actualUnmuteCmd(pluginData, interaction, options.user, attachments, mod, ppId, convertedTime, options.reason); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/unmute/actualUnmuteCmd.ts b/backend/src/plugins/ModActions/commands/unmute/actualUnmuteCmd.ts new file mode 100644 index 00000000..a6fc768a --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unmute/actualUnmuteCmd.ts @@ -0,0 +1,60 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; +import humanizeDuration from "humanize-duration"; +import { GuildPluginData } from "knub"; +import { UnknownUser, asSingleLine, renderUsername } from "../../../../utils.js"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualUnmuteCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + user: User | UnknownUser, + attachments: Array, + mod: GuildMember, + ppId?: string, + time?: number, + reason?: string | null, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const formattedReason = + reason || attachments.length > 0 + ? await formatReasonWithMessageLinkForAttachments(pluginData, reason ?? "", context, attachments) + : undefined; + + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + const result = await mutesPlugin.unmuteUser(user.id, time, { + modId: mod.id, + ppId: ppId ?? undefined, + reason: formattedReason, + }); + + if (!result) { + pluginData.state.common.sendErrorMessage(context, "User is not muted!"); + return; + } + + // Confirm the action to the moderator + if (time) { + const timeUntilUnmute = time && humanizeDuration(time); + pluginData.state.common.sendSuccessMessage( + context, + asSingleLine(` + Unmuting **${renderUsername(user)}** + in ${timeUntilUnmute} (Case #${result.case.case_number}) + `), + ); + } else { + pluginData.state.common.sendSuccessMessage( + context, + asSingleLine(` + Unmuted **${renderUsername(user)}** + (Case #${result.case.case_number}) + `), + ); + } +} 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 72e46fde..9e84b83f 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.js"; -import { updateCase } from "../functions/updateCase.js"; -import { modActionsCmd } from "../types.js"; +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { updateCase } from "../../functions/updateCase.js"; +import { modActionsMsgCmd } from "../../types.js"; -export const UpdateCmd = modActionsCmd({ +export const UpdateMsgCmd = modActionsMsgCmd({ trigger: ["update", "reason"], permission: "can_note", description: @@ -19,6 +19,6 @@ export const UpdateCmd = modActionsCmd({ ], async run({ pluginData, message: msg, args }) { - await updateCase(pluginData, msg, args); + await updateCase(pluginData, msg, 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..a5a664a8 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/update/UpdateSlashCmd.ts @@ -0,0 +1,36 @@ +import { slashOptions } from "knub"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { updateCase } from "../../functions/updateCase.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_UPDATE } from "../constants.js"; + +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 = modActionsSlashCmd({ + 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 interaction.deferReply({ ephemeral: true }); + + await updateCase( + pluginData, + interaction, + interaction.user, + options["case-number"] ? Number(options["case-number"]) : null, + options.reason ?? "", + 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..7e07b2f9 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/warn/WarnMsgCmd.ts @@ -0,0 +1,79 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { canActOn, hasPermission } from "../../../../pluginUtils.js"; +import { errorMessage, resolveMember, resolveUser } from "../../../../utils.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualWarnCmd } from "./actualWarnCmd.js"; + +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) { + await pluginData.state.common.sendErrorMessage(msg, `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) { + await pluginData.state.common.sendErrorMessage(msg, `User is banned`); + } else { + await pluginData.state.common.sendErrorMessage(msg, `User not found on the server`); + } + + return; + } + + // Make sure we're allowed to warn this member + if (!canActOn(pluginData, msg.member, memberToWarn)) { + await pluginData.state.common.sendErrorMessage(msg, "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) { + await pluginData.state.common.sendErrorMessage(msg, e.message); + return; + } + + actualWarnCmd( + pluginData, + msg, + 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..f0f8c197 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/warn/WarnSlashCmd.ts @@ -0,0 +1,116 @@ +import { ChannelType, GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { canActOn, hasPermission } from "../../../../pluginUtils.js"; +import { UserNotificationMethod, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualWarnCmd } from "./actualWarnCmd.js"; + +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", + }), +]; + +export const WarnSlashCmd = modActionsSlashCmd({ + 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 }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + await pluginData.state.common.sendErrorMessage( + 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) { + await pluginData.state.common.sendErrorMessage(interaction, `User is banned`); + } else { + await pluginData.state.common.sendErrorMessage(interaction, `User not found on the server`); + } + + return; + } + + // Make sure we're allowed to warn this member + if (!canActOn(pluginData, interaction.member as GuildMember, memberToWarn)) { + await pluginData.state.common.sendErrorMessage(interaction, "Cannot warn: insufficient permissions"); + return; + } + + let mod = interaction.member as GuildMember; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + await pluginData.state.common.sendErrorMessage( + interaction, + "You don't have permission to act as another moderator", + ); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + await pluginData.state.common.sendErrorMessage(interaction, e.message); + return; + } + + actualWarnCmd( + pluginData, + interaction, + interaction.user.id, + mod, + memberToWarn, + options.reason ?? "", + attachments, + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/warn/actualWarnCmd.ts b/backend/src/plugins/ModActions/commands/warn/actualWarnCmd.ts new file mode 100644 index 00000000..2decfd28 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/warn/actualWarnCmd.ts @@ -0,0 +1,71 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { UserNotificationMethod, renderUsername } from "../../../../utils.js"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { warnMember } from "../../functions/warnMember.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualWarnCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + authorId: string, + mod: GuildMember, + memberToWarn: GuildMember, + reason: string, + attachments: Attachment[], + contactMethods?: UserNotificationMethod[], +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const config = pluginData.config.get(); + const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + const formattedReasonWithAttachments = 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) { + await pluginData.state.common.sendErrorMessage(context, "Warn cancelled by moderator"); + return; + } + } + + const warnResult = await warnMember(pluginData, memberToWarn, formattedReason, formattedReasonWithAttachments, { + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== authorId ? authorId : undefined, + reason: formattedReason, + }, + retryPromptContext: context, + }); + + if (warnResult.status === "failed") { + const failReason = warnResult.error ? `: ${warnResult.error}` : ""; + + await pluginData.state.common.sendErrorMessage(context, `Failed to warn user${failReason}`); + + return; + } + + const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : ""; + + await pluginData.state.common.sendSuccessMessage( + context, + `Warned **${renderUsername(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 c0b9fb53..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.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { DAYS, SECONDS, errorMessage, renderUsername, resolveMember, resolveUser } from "../../../utils.js"; -import { IgnoredEventType, ModActionsPluginType } from "../types.js"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments.js"; -import { ignoreEvent } from "./ignoreEvent.js"; -import { isBanned } from "./isBanned.js"; -import { kickMember } from "./kickMember.js"; -import { readContactMethodsFromArgs } from "./readContactMethodsFromArgs.js"; - -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 **${renderUsername(memberToKick)}** (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 2eac70ef..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.js"; -import { logger } from "../../../logger.js"; -import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { UnknownUser, asSingleLine, isDiscordAPIError, renderUsername } from "../../../utils.js"; -import { MutesPlugin } from "../../Mutes/MutesPlugin.js"; -import { MuteResult } from "../../Mutes/types.js"; -import { ModActionsPluginType } from "../types.js"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments.js"; -import { readContactMethodsFromArgs } from "./readContactMethodsFromArgs.js"; - -/** - * 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 **${renderUsername(user)}**'s - mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number}) - `); - } else { - response = asSingleLine(` - Muted **${renderUsername(user)}** - for ${timeUntilUnmute} (Case #${muteResult.case.case_number}) - `); - } - } else { - if (muteResult.updatedExistingMute) { - response = asSingleLine(` - Updated **${renderUsername(user)}**'s - mute to indefinite (Case #${muteResult.case.case_number}) - `); - } else { - response = asSingleLine(` - Muted **${renderUsername(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 d4e93100..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.js"; -import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin.js"; -import { UnknownUser, asSingleLine, renderUsername } from "../../../utils.js"; -import { ModActionsPluginType } from "../types.js"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments.js"; - -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 **${renderUsername(user)}** - in ${timeUntilUnmute} (Case #${result.case.case_number}) - `), - ); - } else { - sendSuccessMessage( - pluginData, - msg.channel, - asSingleLine(` - Unmuted **${renderUsername(user)}** - (Case #${result.case.case_number}) - `), - ); - } -} diff --git a/backend/src/plugins/ModActions/functions/attachmentLinkReaction.ts b/backend/src/plugins/ModActions/functions/attachmentLinkReaction.ts new file mode 100644 index 00000000..97d5c8a5 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/attachmentLinkReaction.ts @@ -0,0 +1,48 @@ +import { ChatInputCommandInteraction, Message, TextBasedChannel } from "discord.js"; +import { AnyPluginData, GuildPluginData } from "knub"; +import { ModActionsPluginType } from "../types.js"; + +export function shouldReactToAttachmentLink(pluginData: GuildPluginData) { + const config = pluginData.config.get(); + + return !config.attachment_link_reaction || config.attachment_link_reaction !== "none"; +} + +export function attachmentLinkShouldRestrict(pluginData: GuildPluginData) { + return pluginData.config.get().attachment_link_reaction === "restrict"; +} + +export function detectAttachmentLink(reason: string | null | undefined) { + return reason && /https:\/\/(cdn|media)\.discordapp\.(com|net)\/(ephemeral-)?attachments/gu.test(reason); +} + +export function sendAttachmentLinkDetectionErrorMessage( + pluginData: AnyPluginData, + context: TextBasedChannel | Message | ChatInputCommandInteraction, + restricted = false, +) { + const emoji = pluginData.state.common.getErrorEmoji(); + + pluginData.state.common.sendErrorMessage( + context, + "You manually added a Discord attachment link to the reason. This link will only work for a limited time.\n" + + "You should instead **re-upload** the attachment with the command, in the same message.\n\n" + + (restricted ? `${emoji} **Command canceled.** ${emoji}` : "").trim(), + ); +} + +export async function handleAttachmentLinkDetectionAndGetRestriction( + pluginData: GuildPluginData, + context: TextBasedChannel | Message | ChatInputCommandInteraction, + reason: string | null | undefined, +) { + if (!shouldReactToAttachmentLink(pluginData) || !detectAttachmentLink(reason)) { + return false; + } + + const restricted = attachmentLinkShouldRestrict(pluginData); + + sendAttachmentLinkDetectionErrorMessage(pluginData, context, restricted); + + return restricted; +} diff --git a/backend/src/plugins/ModActions/functions/banUserId.ts b/backend/src/plugins/ModActions/functions/banUserId.ts index 2ac01af3..d432de23 100644 --- a/backend/src/plugins/ModActions/functions/banUserId.ts +++ b/backend/src/plugins/ModActions/functions/banUserId.ts @@ -30,6 +30,7 @@ export async function banUserId( pluginData: GuildPluginData, userId: string, reason?: string, + reasonWithAttachments?: string, banOptions: BanOptions = {}, banTime?: number, ): Promise { @@ -45,7 +46,7 @@ export async function banUserId( // Attempt to message the user *before* banning them, as doing it after may not be possible const member = await resolveMember(pluginData.client, pluginData.guild, userId); let notifyResult: UserNotificationResult = { method: null, success: true }; - if (reason && member) { + if (reasonWithAttachments && member) { const contactMethods = banOptions?.contactMethods ? banOptions.contactMethods : getDefaultContactMethods(pluginData, "ban"); @@ -58,7 +59,7 @@ export async function banUserId( config.ban_message, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, - reason, + reason: reasonWithAttachments, moderator: banOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId)) : null, @@ -82,7 +83,7 @@ export async function banUserId( config.tempban_message, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, - reason, + reason: reasonWithAttachments, moderator: banOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId)) : null, diff --git a/backend/src/plugins/ModActions/functions/clearTempban.ts b/backend/src/plugins/ModActions/functions/clearTempban.ts index e0962e9c..b2f80d83 100644 --- a/backend/src/plugins/ModActions/functions/clearTempban.ts +++ b/backend/src/plugins/ModActions/functions/clearTempban.ts @@ -10,7 +10,6 @@ import { resolveUser } from "../../../utils.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { IgnoredEventType, ModActionsPluginType } from "../types.js"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments.js"; import { ignoreEvent } from "./ignoreEvent.js"; import { isBanned } from "./isBanned.js"; @@ -21,11 +20,9 @@ export async function clearTempban(pluginData: GuildPluginData, + reason: string, + context: Message | ChatInputCommandInteraction, + attachments: Attachment[], +) { + if (isContextMessage(context)) { + const allAttachments = [...new Set([...context.attachments.values(), ...attachments])]; + + return allAttachments.length > 0 ? ((reason || "") + " " + context.url).trim() : reason; + } + + if (attachments.length < 1) { + return reason; + } + + const attachmentsMessage = await pluginData.state.common.storeAttachmentsAsMessage(attachments, context.channel); + + return ((reason || "") + " " + attachmentsMessage.url).trim(); +} + +export function formatReasonWithAttachments(reason: string, attachments: Attachment[]) { + const attachmentUrls = attachments.map((a) => a.url); + return ((reason || "") + " " + attachmentUrls.join(" ")).trim(); +} diff --git a/backend/src/plugins/ModActions/functions/formatReasonWithAttachments.ts b/backend/src/plugins/ModActions/functions/formatReasonWithAttachments.ts deleted file mode 100644 index 3fd92ee8..00000000 --- a/backend/src/plugins/ModActions/functions/formatReasonWithAttachments.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Attachment } from "discord.js"; - -export function formatReasonWithAttachments(reason: string, attachments: Attachment[]) { - const attachmentUrls = attachments.map((a) => a.url); - return ((reason || "") + " " + attachmentUrls.join(" ")).trim(); -} diff --git a/backend/src/plugins/ModActions/functions/hasModActionPerm.ts b/backend/src/plugins/ModActions/functions/hasModActionPerm.ts new file mode 100644 index 00000000..501e927d --- /dev/null +++ b/backend/src/plugins/ModActions/functions/hasModActionPerm.ts @@ -0,0 +1,35 @@ +import { GuildMember, Snowflake } from "discord.js"; +import { GuildPluginData } from "knub"; +import { ModActionsPluginType } from "../types.js"; + +export async function hasNotePermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId })).can_note; +} + +export async function hasWarnPermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId })).can_warn; +} + +export async function hasMutePermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId })).can_mute; +} + +export async function hasBanPermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId })).can_ban; +} diff --git a/backend/src/plugins/ModActions/functions/hasMutePerm.ts b/backend/src/plugins/ModActions/functions/hasMutePerm.ts deleted file mode 100644 index be3096ee..00000000 --- a/backend/src/plugins/ModActions/functions/hasMutePerm.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { GuildMember, Snowflake } from "discord.js"; -import { GuildPluginData } from "knub"; -import { ModActionsPluginType } from "../types.js"; - -export async function hasMutePermission( - pluginData: GuildPluginData, - member: GuildMember, - channelId: Snowflake, -) { - return (await pluginData.config.getMatchingConfig({ member, channelId })).can_mute; -} diff --git a/backend/src/plugins/ModActions/functions/kickMember.ts b/backend/src/plugins/ModActions/functions/kickMember.ts index 527f6163..c1f2f7d2 100644 --- a/backend/src/plugins/ModActions/functions/kickMember.ts +++ b/backend/src/plugins/ModActions/functions/kickMember.ts @@ -3,13 +3,7 @@ import { GuildPluginData } from "knub"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { LogType } from "../../../data/LogType.js"; import { renderTemplate, TemplateParseError, TemplateSafeValueContainer } from "../../../templateFormatter.js"; -import { - createUserNotificationError, - notifyUser, - resolveUser, - ucfirst, - UserNotificationResult, -} from "../../../utils.js"; +import { createUserNotificationError, notifyUser, resolveUser, ucfirst, UserNotificationResult } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; @@ -24,13 +18,14 @@ export async function kickMember( pluginData: GuildPluginData, member: GuildMember, reason?: string, + reasonWithAttachments?: string, kickOptions: KickOptions = {}, ): Promise { const config = pluginData.config.get(); // Attempt to message the user *before* kicking them, as doing it after may not be possible let notifyResult: UserNotificationResult = { method: null, success: true }; - if (reason && member) { + if (reasonWithAttachments && member) { const contactMethods = kickOptions?.contactMethods ? kickOptions.contactMethods : getDefaultContactMethods(pluginData, "kick"); @@ -43,7 +38,7 @@ export async function kickMember( config.kick_message, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, - reason, + reason: reasonWithAttachments, moderator: kickOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, kickOptions.caseArgs.modId)) : null, diff --git a/backend/src/plugins/ModActions/functions/readContactMethodsFromArgs.ts b/backend/src/plugins/ModActions/functions/readContactMethodsFromArgs.ts index efa3b8bc..47f207f5 100644 --- a/backend/src/plugins/ModActions/functions/readContactMethodsFromArgs.ts +++ b/backend/src/plugins/ModActions/functions/readContactMethodsFromArgs.ts @@ -2,8 +2,8 @@ import { GuildTextBasedChannel } from "discord.js"; import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils.js"; export function readContactMethodsFromArgs(args: { - notify?: string; - "notify-channel"?: GuildTextBasedChannel; + notify?: string | null; + "notify-channel"?: GuildTextBasedChannel | null; }): null | UserNotificationMethod[] { if (args.notify) { if (args.notify === "dm") { diff --git a/backend/src/plugins/ModActions/functions/updateCase.ts b/backend/src/plugins/ModActions/functions/updateCase.ts index 721c083c..6d78ce8b 100644 --- a/backend/src/plugins/ModActions/functions/updateCase.ts +++ b/backend/src/plugins/ModActions/functions/updateCase.ts @@ -1,44 +1,57 @@ -import { Message } from "discord.js"; +import { Attachment, ChatInputCommandInteraction, Message, User } from "discord.js"; +import { GuildPluginData } from "knub"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { Case } from "../../../data/entities/Case.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; +import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments.js"; +import { ModActionsPluginType } from "../types.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "./attachmentLinkReaction.js"; +import { formatReasonWithMessageLinkForAttachments } from "./formatReasonForAttachments.js"; -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: Message | ChatInputCommandInteraction, + author: User, + caseNumber?: number | null, + note = "", + 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"); + pluginData.state.common.sendErrorMessage(context, "Case not found"); return; } - if (!args.note && msg.attachments.size === 0) { - sendErrorMessage(pluginData, msg.channel, "Text or attachment required"); + if (note.length === 0 && attachments.length === 0) { + pluginData.state.common.sendErrorMessage(context, "Text or attachment required"); return; } - const note = formatReasonWithAttachments(args.note, [...msg.attachments.values()]); + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, note)) { + return; + } + + const formattedNote = await formatReasonWithMessageLinkForAttachments(pluginData, note, context, 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`); + pluginData.state.common.sendSuccessMessage(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 a7a8f531..9e077234 100644 --- a/backend/src/plugins/ModActions/functions/warnMember.ts +++ b/backend/src/plugins/ModActions/functions/warnMember.ts @@ -2,13 +2,7 @@ import { GuildMember, Snowflake } from "discord.js"; import { GuildPluginData } from "knub"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; -import { - UserNotificationResult, - createUserNotificationError, - notifyUser, - resolveUser, - ucfirst, -} from "../../../utils.js"; +import { UserNotificationResult, createUserNotificationError, notifyUser, resolveUser, ucfirst } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { waitForButtonConfirm } from "../../../utils/waitForInteraction.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; @@ -20,6 +14,7 @@ export async function warnMember( pluginData: GuildPluginData, member: GuildMember, reason: string, + reasonWithAttachments: string, warnOptions: WarnOptions = {}, ): Promise { const config = pluginData.config.get(); @@ -32,7 +27,7 @@ export async function warnMember( config.warn_message, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, - reason, + reason: reasonWithAttachments, moderator: warnOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, warnOptions.caseArgs.modId)) : null, @@ -56,20 +51,20 @@ 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 }, - ); + if (!warnOptions.retryPromptContext) { + return { + status: "failed", + error: "Failed to message user", + }; + } - if (!reply) { - return { - status: "failed", - error: "Failed to message user", - }; - } - } else { + 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 51044fd3..c7dcc6e9 100644 --- a/backend/src/plugins/ModActions/types.ts +++ b/backend/src/plugins/ModActions/types.ts @@ -1,6 +1,13 @@ -import { GuildTextBasedChannel } from "discord.js"; +import { ChatInputCommandInteraction, Message } from "discord.js"; import { EventEmitter } from "events"; -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import { + BasePluginType, + guildPluginEventListener, + guildPluginMessageCommand, + guildPluginSlashCommand, + guildPluginSlashGroup, + pluginUtils, +} from "knub"; import z from "zod"; import { Queue } from "../../Queue.js"; import { GuildCases } from "../../data/GuildCases.js"; @@ -10,6 +17,9 @@ import { GuildTempbans } from "../../data/GuildTempbans.js"; import { Case } from "../../data/entities/Case.js"; import { UserNotificationMethod, UserNotificationResult } from "../../utils.js"; import { CaseArgs } from "../Cases/types.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; + +export type AttachmentLinkReactionType = "none" | "warn" | "restrict" | null; export const zModActionsConfig = z.strictObject({ dm_on_warn: z.boolean(), @@ -29,6 +39,7 @@ export const zModActionsConfig = z.strictObject({ warn_notify_threshold: z.number(), warn_notify_message: z.string(), ban_delete_message_days: z.number(), + attachment_link_reaction: z.nullable(z.union([z.literal("none"), z.literal("warn"), z.literal("restrict")])), can_note: z.boolean(), can_warn: z.boolean(), can_mute: z.boolean(), @@ -74,6 +85,8 @@ export interface ModActionsPluginType extends BasePluginType { massbanQueue: Queue; events: ModActionsEventEmitter; + + common: pluginUtils.PluginPublicInterface; }; } @@ -126,7 +139,7 @@ export type WarnMemberNotifyRetryCallback = () => boolean | Promise; export interface WarnOptions { caseArgs?: Partial | null; contactMethods?: UserNotificationMethod[] | null; - retryPromptChannel?: GuildTextBasedChannel | null; + retryPromptContext?: Message | ChatInputCommandInteraction | null; isAutomodAction?: boolean; } @@ -146,5 +159,7 @@ export interface BanOptions { export type ModActionType = "note" | "warn" | "mute" | "unmute" | "kick" | "ban" | "unban"; -export const modActionsCmd = guildPluginMessageCommand(); +export const modActionsMsgCmd = guildPluginMessageCommand(); +export const modActionsSlashGroup = guildPluginSlashGroup(); +export const modActionsSlashCmd = guildPluginSlashCommand(); export const modActionsEvt = guildPluginEventListener(); diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts index 7bd47d72..9eccb855 100644 --- a/backend/src/plugins/Mutes/MutesPlugin.ts +++ b/backend/src/plugins/Mutes/MutesPlugin.ts @@ -8,6 +8,7 @@ import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildMutes } from "../../data/GuildMutes.js"; import { makePublicFn } from "../../pluginUtils.js"; import { CasesPlugin } from "../Cases/CasesPlugin.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin.js"; import { ClearBannedMutesCmd } from "./commands/ClearBannedMutesCmd.js"; @@ -109,6 +110,10 @@ export const MutesPlugin = guildPlugin()({ state.events = new EventEmitter(); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts b/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts index 1c37b368..80a9a49e 100644 --- a/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts +++ b/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts @@ -1,5 +1,4 @@ import { Snowflake } from "discord.js"; -import { sendSuccessMessage } from "../../../pluginUtils.js"; import { mutesCmd } from "../types.js"; export const ClearBannedMutesCmd = mutesCmd({ @@ -25,6 +24,6 @@ export const ClearBannedMutesCmd = mutesCmd({ } } - sendSuccessMessage(pluginData, msg.channel, `Cleared ${cleared} mutes from banned users!`); + void pluginData.state.common.sendSuccessMessage(msg, `Cleared ${cleared} mutes from banned users!`); }, }); diff --git a/backend/src/plugins/Mutes/commands/ClearMutesCmd.ts b/backend/src/plugins/Mutes/commands/ClearMutesCmd.ts index be5ffa13..773ac06d 100644 --- a/backend/src/plugins/Mutes/commands/ClearMutesCmd.ts +++ b/backend/src/plugins/Mutes/commands/ClearMutesCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { mutesCmd } from "../types.js"; export const ClearMutesCmd = mutesCmd({ @@ -23,13 +22,15 @@ export const ClearMutesCmd = mutesCmd({ } if (failed.length !== args.userIds.length) { - sendSuccessMessage(pluginData, msg.channel, `**${args.userIds.length - failed.length} active mute(s) cleared**`); + void pluginData.state.common.sendSuccessMessage( + msg, + `**${args.userIds.length - failed.length} active mute(s) cleared**`, + ); } if (failed.length) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `**${failed.length}/${args.userIds.length} IDs failed**, they are not muted: ${failed.join(" ")}`, ); } diff --git a/backend/src/plugins/Mutes/commands/ClearMutesWithoutRoleCmd.ts b/backend/src/plugins/Mutes/commands/ClearMutesWithoutRoleCmd.ts index b65c97ab..c225d407 100644 --- a/backend/src/plugins/Mutes/commands/ClearMutesWithoutRoleCmd.ts +++ b/backend/src/plugins/Mutes/commands/ClearMutesWithoutRoleCmd.ts @@ -1,5 +1,4 @@ import { Snowflake } from "discord.js"; -import { sendSuccessMessage } from "../../../pluginUtils.js"; import { resolveMember } from "../../../utils.js"; import { mutesCmd } from "../types.js"; @@ -26,6 +25,9 @@ export const ClearMutesWithoutRoleCmd = mutesCmd({ } } - sendSuccessMessage(pluginData, msg.channel, `Cleared ${cleared} mutes from members that don't have the mute role`); + void pluginData.state.common.sendSuccessMessage( + msg, + `Cleared ${cleared} mutes from members that don't have the mute role`, + ); }, }); diff --git a/backend/src/plugins/Mutes/functions/muteUser.ts b/backend/src/plugins/Mutes/functions/muteUser.ts index e57ae762..bb2b60ef 100644 --- a/backend/src/plugins/Mutes/functions/muteUser.ts +++ b/backend/src/plugins/Mutes/functions/muteUser.ts @@ -35,6 +35,7 @@ export async function muteUser( userId: string, muteTime?: number, reason?: string, + reasonWithAttachments?: string, muteOptions: MuteOptions = {}, removeRolesOnMuteOverride: boolean | string[] | null = null, restoreRolesOnMuteOverride: boolean | string[] | null = null, @@ -196,7 +197,7 @@ export async function muteUser( template, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, - reason: reason || "None", + reason: reasonWithAttachments || "None", time: timeUntilUnmuteStr, moderator: muteOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, muteOptions.caseArgs.modId)) @@ -245,10 +246,12 @@ export async function muteUser( if (theCase) { // Update old case const noteDetails = [`Mute updated to ${muteTime ? timeUntilUnmuteStr : "indefinite"}`]; - const reasons = reason ? [reason] : []; + const reasons = reason ? [reason] : [""]; // Empty string so that there is a case update even without reason + if (muteOptions.caseArgs?.extraNotes) { reasons.push(...muteOptions.caseArgs.extraNotes); } + for (const noteReason of reasons) { await casesPlugin.createCaseNote({ caseId: existingMute!.case_id, diff --git a/backend/src/plugins/Mutes/types.ts b/backend/src/plugins/Mutes/types.ts index 3bbc9d99..4a4c01de 100644 --- a/backend/src/plugins/Mutes/types.ts +++ b/backend/src/plugins/Mutes/types.ts @@ -1,6 +1,6 @@ import { GuildMember } from "discord.js"; import { EventEmitter } from "events"; -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; @@ -10,6 +10,7 @@ import { Case } from "../../data/entities/Case.js"; import { Mute } from "../../data/entities/Mute.js"; import { UserNotificationMethod, UserNotificationResult, zSnowflake } from "../../utils.js"; import { CaseArgs } from "../Cases/types.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zMutesConfig = z.strictObject({ mute_role: zSnowflake.nullable(), @@ -53,6 +54,8 @@ export interface MutesPluginType extends BasePluginType { unregisterTimeoutMuteToRenewListener: () => void; events: MutesEventEmitter; + + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/NameHistory/NameHistoryPlugin.ts b/backend/src/plugins/NameHistory/NameHistoryPlugin.ts index 770ce446..1cb28123 100644 --- a/backend/src/plugins/NameHistory/NameHistoryPlugin.ts +++ b/backend/src/plugins/NameHistory/NameHistoryPlugin.ts @@ -2,6 +2,7 @@ import { PluginOptions, guildPlugin } from "knub"; import { Queue } from "../../Queue.js"; import { GuildNicknameHistory } from "../../data/GuildNicknameHistory.js"; import { UsernameHistory } from "../../data/UsernameHistory.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { NamesCmd } from "./commands/NamesCmd.js"; import { NameHistoryPluginType, zNameHistoryConfig } from "./types.js"; @@ -44,4 +45,8 @@ export const NameHistoryPlugin = guildPlugin()({ state.usernameHistory = new UsernameHistory(); state.updateQueue = new Queue(); }, + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/NameHistory/commands/NamesCmd.ts b/backend/src/plugins/NameHistory/commands/NamesCmd.ts index ce1ccaad..f7e27ce4 100644 --- a/backend/src/plugins/NameHistory/commands/NamesCmd.ts +++ b/backend/src/plugins/NameHistory/commands/NamesCmd.ts @@ -4,7 +4,6 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { MAX_NICKNAME_ENTRIES_PER_USER } from "../../../data/GuildNicknameHistory.js"; import { MAX_USERNAME_ENTRIES_PER_USER } from "../../../data/UsernameHistory.js"; import { NICKNAME_RETENTION_PERIOD } from "../../../data/cleanup/nicknames.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { DAYS, renderUsername } from "../../../utils.js"; import { nameHistoryCmd } from "../types.js"; @@ -21,7 +20,7 @@ export const NamesCmd = nameHistoryCmd({ const usernames = await pluginData.state.usernameHistory.getByUserId(args.userId); if (nicknames.length === 0 && usernames.length === 0) { - sendErrorMessage(pluginData, msg.channel, "No name history found"); + void pluginData.state.common.sendErrorMessage(msg, "No name history found"); return; } diff --git a/backend/src/plugins/NameHistory/types.ts b/backend/src/plugins/NameHistory/types.ts index 708a15f2..d36763b7 100644 --- a/backend/src/plugins/NameHistory/types.ts +++ b/backend/src/plugins/NameHistory/types.ts @@ -1,8 +1,9 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { Queue } from "../../Queue.js"; import { GuildNicknameHistory } from "../../data/GuildNicknameHistory.js"; import { UsernameHistory } from "../../data/UsernameHistory.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zNameHistoryConfig = z.strictObject({ can_view: z.boolean(), @@ -14,6 +15,7 @@ export interface NameHistoryPluginType extends BasePluginType { nicknameHistory: GuildNicknameHistory; usernameHistory: UsernameHistory; updateQueue: Queue; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/Persist/events/LoadDataEvt.ts b/backend/src/plugins/Persist/events/LoadDataEvt.ts index 799483a5..47ccaedf 100644 --- a/backend/src/plugins/Persist/events/LoadDataEvt.ts +++ b/backend/src/plugins/Persist/events/LoadDataEvt.ts @@ -1,6 +1,6 @@ import { GuildMember, PermissionFlagsBits } from "discord.js"; import { GuildPluginData } from "knub"; -import intersection from "lodash/intersection.js"; +import intersection from "lodash.intersection"; import { PersistedData } from "../../../data/entities/PersistedData.js"; import { SECONDS } from "../../../utils.js"; import { canAssignRole } from "../../../utils/canAssignRole.js"; diff --git a/backend/src/plugins/Phisherman/functions/getDomainInfo.ts b/backend/src/plugins/Phisherman/functions/getDomainInfo.ts index b8fabcb8..f2913194 100644 --- a/backend/src/plugins/Phisherman/functions/getDomainInfo.ts +++ b/backend/src/plugins/Phisherman/functions/getDomainInfo.ts @@ -1,9 +1,5 @@ import { GuildPluginData } from "knub"; -import { - getPhishermanDomainInfo, - phishermanDomainIsSafe, - trackPhishermanCaughtDomain, -} from "../../../data/Phisherman.js"; +import { getPhishermanDomainInfo, phishermanDomainIsSafe, trackPhishermanCaughtDomain } from "../../../data/Phisherman.js"; import { PhishermanDomainInfo } from "../../../data/types/phisherman.js"; import { PhishermanPluginType } from "../types.js"; diff --git a/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts b/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts index 6f75e747..636208ee 100644 --- a/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts +++ b/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts @@ -1,5 +1,6 @@ import { PluginOptions, guildPlugin } from "knub"; import { GuildPingableRoles } from "../../data/GuildPingableRoles.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { PingableRoleDisableCmd } from "./commands/PingableRoleDisableCmd.js"; import { PingableRoleEnableCmd } from "./commands/PingableRoleEnableCmd.js"; import { PingableRolesPluginType, zPingableRolesConfig } from "./types.js"; @@ -44,4 +45,8 @@ export const PingableRolesPlugin = guildPlugin()({ state.cache = new Map(); state.timeouts = new Map(); }, + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/PingableRoles/commands/PingableRoleDisableCmd.ts b/backend/src/plugins/PingableRoles/commands/PingableRoleDisableCmd.ts index e27cd146..cc0d9887 100644 --- a/backend/src/plugins/PingableRoles/commands/PingableRoleDisableCmd.ts +++ b/backend/src/plugins/PingableRoles/commands/PingableRoleDisableCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { pingableRolesCmd } from "../types.js"; export const PingableRoleDisableCmd = pingableRolesCmd({ @@ -14,16 +13,18 @@ export const PingableRoleDisableCmd = pingableRolesCmd({ async run({ message: msg, args, pluginData }) { const pingableRole = await pluginData.state.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id); if (!pingableRole) { - sendErrorMessage(pluginData, msg.channel, `**${args.role.name}** is not set as pingable in <#${args.channelId}>`); + void pluginData.state.common.sendErrorMessage( + msg, + `**${args.role.name}** is not set as pingable in <#${args.channelId}>`, + ); return; } await pluginData.state.pingableRoles.delete(args.channelId, args.role.id); pluginData.state.cache.delete(args.channelId); - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `**${args.role.name}** is no longer set as pingable in <#${args.channelId}>`, ); }, diff --git a/backend/src/plugins/PingableRoles/commands/PingableRoleEnableCmd.ts b/backend/src/plugins/PingableRoles/commands/PingableRoleEnableCmd.ts index 6c12d7a5..20b3cc8f 100644 --- a/backend/src/plugins/PingableRoles/commands/PingableRoleEnableCmd.ts +++ b/backend/src/plugins/PingableRoles/commands/PingableRoleEnableCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { pingableRolesCmd } from "../types.js"; export const PingableRoleEnableCmd = pingableRolesCmd({ @@ -17,9 +16,8 @@ export const PingableRoleEnableCmd = pingableRolesCmd({ args.role.id, ); if (existingPingableRole) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `**${args.role.name}** is already set as pingable in <#${args.channelId}>`, ); return; @@ -28,9 +26,8 @@ export const PingableRoleEnableCmd = pingableRolesCmd({ await pluginData.state.pingableRoles.add(args.channelId, args.role.id); pluginData.state.cache.delete(args.channelId); - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `**${args.role.name}** has been set as pingable in <#${args.channelId}>`, ); }, diff --git a/backend/src/plugins/PingableRoles/types.ts b/backend/src/plugins/PingableRoles/types.ts index d2e00988..b79ac27f 100644 --- a/backend/src/plugins/PingableRoles/types.ts +++ b/backend/src/plugins/PingableRoles/types.ts @@ -1,7 +1,8 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { GuildPingableRoles } from "../../data/GuildPingableRoles.js"; import { PingableRole } from "../../data/entities/PingableRole.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zPingableRolesConfig = z.strictObject({ can_manage: z.boolean(), @@ -14,6 +15,7 @@ export interface PingableRolesPluginType extends BasePluginType { pingableRoles: GuildPingableRoles; cache: Map; timeouts: Map; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/Post/PostPlugin.ts b/backend/src/plugins/Post/PostPlugin.ts index f843179d..b5e9a83a 100644 --- a/backend/src/plugins/Post/PostPlugin.ts +++ b/backend/src/plugins/Post/PostPlugin.ts @@ -3,6 +3,7 @@ import { onGuildEvent } from "../../data/GuildEvents.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildScheduledPosts } from "../../data/GuildScheduledPosts.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { EditCmd } from "./commands/EditCmd.js"; @@ -55,6 +56,10 @@ export const PostPlugin = guildPlugin()({ state.logs = new GuildLogs(guild.id); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/Post/commands/EditCmd.ts b/backend/src/plugins/Post/commands/EditCmd.ts index 9568da19..e3867dbb 100644 --- a/backend/src/plugins/Post/commands/EditCmd.ts +++ b/backend/src/plugins/Post/commands/EditCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { postCmd } from "../types.js"; import { formatContent } from "../util/formatContent.js"; @@ -15,18 +14,18 @@ export const EditCmd = postCmd({ async run({ message: msg, args, pluginData }) { const targetMessage = await args.message.channel.messages.fetch(args.message.messageId); if (!targetMessage) { - sendErrorMessage(pluginData, msg.channel, "Unknown message"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown message"); return; } if (targetMessage.author.id !== pluginData.client.user!.id) { - sendErrorMessage(pluginData, msg.channel, "Message wasn't posted by me"); + void pluginData.state.common.sendErrorMessage(msg, "Message wasn't posted by me"); return; } targetMessage.channel.messages.edit(targetMessage.id, { content: formatContent(args.content), }); - sendSuccessMessage(pluginData, msg.channel, "Message edited"); + void pluginData.state.common.sendSuccessMessage(msg, "Message edited"); }, }); diff --git a/backend/src/plugins/Post/commands/EditEmbedCmd.ts b/backend/src/plugins/Post/commands/EditEmbedCmd.ts index 2e69d915..01da2c14 100644 --- a/backend/src/plugins/Post/commands/EditEmbedCmd.ts +++ b/backend/src/plugins/Post/commands/EditEmbedCmd.ts @@ -1,6 +1,5 @@ import { APIEmbed } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { isValidEmbed, trimLines } from "../../../utils.js"; import { parseColor } from "../../../utils/parseColor.js"; import { rgbToInt } from "../../../utils/rgbToInt.js"; @@ -30,14 +29,14 @@ export const EditEmbedCmd = postCmd({ if (colorRgb) { color = rgbToInt(colorRgb); } else { - sendErrorMessage(pluginData, msg.channel, "Invalid color specified"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid color specified"); return; } } const targetMessage = await args.message.channel.messages.fetch(args.message.messageId); if (!targetMessage) { - sendErrorMessage(pluginData, msg.channel, "Unknown message"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown message"); return; } @@ -51,12 +50,12 @@ export const EditEmbedCmd = postCmd({ try { parsed = JSON.parse(content); } catch (e) { - sendErrorMessage(pluginData, msg.channel, `Syntax error in embed JSON: ${e.message}`); + void pluginData.state.common.sendErrorMessage(msg, `Syntax error in embed JSON: ${e.message}`); return; } if (!isValidEmbed(parsed)) { - sendErrorMessage(pluginData, msg.channel, "Embed is not valid"); + void pluginData.state.common.sendErrorMessage(msg, "Embed is not valid"); return; } @@ -69,7 +68,7 @@ export const EditEmbedCmd = postCmd({ args.message.channel.messages.edit(targetMessage.id, { embeds: [embed], }); - await sendSuccessMessage(pluginData, msg.channel, "Embed edited"); + await pluginData.state.common.sendSuccessMessage(msg, "Embed edited"); if (args.content) { const prefix = pluginData.fullConfig.prefix || "!"; diff --git a/backend/src/plugins/Post/commands/PostEmbedCmd.ts b/backend/src/plugins/Post/commands/PostEmbedCmd.ts index 0b040524..a3a5e60c 100644 --- a/backend/src/plugins/Post/commands/PostEmbedCmd.ts +++ b/backend/src/plugins/Post/commands/PostEmbedCmd.ts @@ -1,6 +1,5 @@ import { APIEmbed } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { isValidEmbed, trimLines } from "../../../utils.js"; import { parseColor } from "../../../utils/parseColor.js"; import { rgbToInt } from "../../../utils/rgbToInt.js"; @@ -31,7 +30,7 @@ export const PostEmbedCmd = postCmd({ const content = args.content || args.maincontent; if (!args.title && !content) { - sendErrorMessage(pluginData, msg.channel, "Title or content required"); + void pluginData.state.common.sendErrorMessage(msg, "Title or content required"); return; } @@ -41,7 +40,7 @@ export const PostEmbedCmd = postCmd({ if (colorRgb) { color = rgbToInt(colorRgb); } else { - sendErrorMessage(pluginData, msg.channel, "Invalid color specified"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid color specified"); return; } } @@ -56,12 +55,12 @@ export const PostEmbedCmd = postCmd({ try { parsed = JSON.parse(content); } catch (e) { - sendErrorMessage(pluginData, msg.channel, `Syntax error in embed JSON: ${e.message}`); + void pluginData.state.common.sendErrorMessage(msg, `Syntax error in embed JSON: ${e.message}`); return; } if (!isValidEmbed(parsed)) { - sendErrorMessage(pluginData, msg.channel, "Embed is not valid"); + void pluginData.state.common.sendErrorMessage(msg, "Embed is not valid"); return; } diff --git a/backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts index 732ca1f8..3854ca94 100644 --- a/backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts +++ b/backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts @@ -1,6 +1,5 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { clearUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { sorter } from "../../../utils.js"; import { postCmd } from "../types.js"; @@ -17,12 +16,12 @@ export const ScheduledPostsDeleteCmd = postCmd({ scheduledPosts.sort(sorter("post_at")); const post = scheduledPosts[args.num - 1]; if (!post) { - sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); + void pluginData.state.common.sendErrorMessage(msg, "Scheduled post not found"); return; } clearUpcomingScheduledPost(post); await pluginData.state.scheduledPosts.delete(post.id); - sendSuccessMessage(pluginData, msg.channel, "Scheduled post deleted!"); + void pluginData.state.common.sendSuccessMessage(msg, "Scheduled post deleted!"); }, }); diff --git a/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts index 6fbd17be..ea979f5c 100644 --- a/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts +++ b/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { sorter } from "../../../utils.js"; import { postCmd } from "../types.js"; import { postMessage } from "../util/postMessage.js"; @@ -17,7 +16,7 @@ export const ScheduledPostsShowCmd = postCmd({ scheduledPosts.sort(sorter("post_at")); const post = scheduledPosts[args.num - 1]; if (!post) { - sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); + void pluginData.state.common.sendErrorMessage(msg, "Scheduled post not found"); return; } diff --git a/backend/src/plugins/Post/types.ts b/backend/src/plugins/Post/types.ts index 1c758b9c..96b11e71 100644 --- a/backend/src/plugins/Post/types.ts +++ b/backend/src/plugins/Post/types.ts @@ -1,8 +1,9 @@ -import { BasePluginType, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildScheduledPosts } from "../../data/GuildScheduledPosts.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zPostConfig = z.strictObject({ can_post: z.boolean(), @@ -14,6 +15,7 @@ export interface PostPluginType extends BasePluginType { savedMessages: GuildSavedMessages; scheduledPosts: GuildScheduledPosts; logs: GuildLogs; + common: pluginUtils.PluginPublicInterface; unregisterGuildEventListener: () => void; }; diff --git a/backend/src/plugins/Post/util/actualPostCmd.ts b/backend/src/plugins/Post/util/actualPostCmd.ts index 93f7ac5c..d1265b02 100644 --- a/backend/src/plugins/Post/util/actualPostCmd.ts +++ b/backend/src/plugins/Post/util/actualPostCmd.ts @@ -3,7 +3,6 @@ import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; import moment from "moment-timezone"; import { registerUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { DBDateFormat, MINUTES, StrictMessageContent, errorMessage, renderUsername } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; @@ -40,11 +39,17 @@ export async function actualPostCmd( if (opts.repeat) { if (opts.repeat < MIN_REPEAT_TIME) { - sendErrorMessage(pluginData, msg.channel, `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`); + void pluginData.state.common.sendErrorMessage( + msg, + `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`, + ); return; } if (opts.repeat > MAX_REPEAT_TIME) { - sendErrorMessage(pluginData, msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`); + void pluginData.state.common.sendErrorMessage( + msg, + `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`, + ); return; } } @@ -55,7 +60,7 @@ export async function actualPostCmd( // Schedule the post to be posted later postAt = await parseScheduleTime(pluginData, msg.author.id, opts.schedule); if (!postAt) { - sendErrorMessage(pluginData, msg.channel, "Invalid schedule time"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid schedule time"); return; } } else if (opts.repeat) { @@ -72,17 +77,16 @@ export async function actualPostCmd( // Invalid time if (!repeatUntil) { - sendErrorMessage(pluginData, msg.channel, "Invalid time specified for -repeat-until"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid time specified for -repeat-until"); return; } if (repeatUntil.isBefore(moment.utc())) { - sendErrorMessage(pluginData, msg.channel, "You can't set -repeat-until in the past"); + void pluginData.state.common.sendErrorMessage(msg, "You can't set -repeat-until in the past"); return; } if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, "Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?", ); return; @@ -90,18 +94,24 @@ export async function actualPostCmd( } else if (opts["repeat-times"]) { repeatTimes = opts["repeat-times"]; if (repeatTimes <= 0) { - sendErrorMessage(pluginData, msg.channel, "-repeat-times must be 1 or more"); + void pluginData.state.common.sendErrorMessage(msg, "-repeat-times must be 1 or more"); return; } } if (repeatUntil && repeatTimes) { - sendErrorMessage(pluginData, msg.channel, "You can only use one of -repeat-until or -repeat-times at once"); + void pluginData.state.common.sendErrorMessage( + msg, + "You can only use one of -repeat-until or -repeat-times at once", + ); return; } if (opts.repeat && !repeatUntil && !repeatTimes) { - sendErrorMessage(pluginData, msg.channel, "You must specify -repeat-until or -repeat-times for repeated messages"); + void pluginData.state.common.sendErrorMessage( + msg, + "You must specify -repeat-until or -repeat-times for repeated messages", + ); return; } @@ -116,7 +126,7 @@ export async function actualPostCmd( // Save schedule/repeat information in DB if (postAt) { if (postAt < moment.utc()) { - sendErrorMessage(pluginData, msg.channel, "Post can't be scheduled to be posted in the past"); + void pluginData.state.common.sendErrorMessage(msg, "Post can't be scheduled to be posted in the past"); return; } @@ -192,6 +202,6 @@ export async function actualPostCmd( } if (targetChannel.id !== msg.channel.id || opts.schedule || opts.repeat) { - sendSuccessMessage(pluginData, msg.channel, successMessage); + void pluginData.state.common.sendSuccessMessage(msg, successMessage); } } diff --git a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts index a3fc731c..6d819fa7 100644 --- a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts +++ b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts @@ -2,6 +2,7 @@ import { PluginOptions, guildPlugin } from "knub"; import { Queue } from "../../Queue.js"; import { GuildReactionRoles } from "../../data/GuildReactionRoles.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { ClearReactionRolesCmd } from "./commands/ClearReactionRolesCmd.js"; import { InitReactionRolesCmd } from "./commands/InitReactionRolesCmd.js"; @@ -63,6 +64,10 @@ export const ReactionRolesPlugin = guildPlugin()({ state.pendingRefreshes = new Set(); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const config = pluginData.config.get(); if (config.button_groups) { diff --git a/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts index 09401a9d..5bfb5176 100644 --- a/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts +++ b/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts @@ -1,6 +1,5 @@ import { Message } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { isDiscordAPIError } from "../../../utils.js"; import { reactionRolesCmd } from "../types.js"; @@ -15,7 +14,7 @@ export const ClearReactionRolesCmd = reactionRolesCmd({ async run({ message: msg, args, pluginData }) { const existingReactionRoles = pluginData.state.reactionRoles.getForMessage(args.message.messageId); if (!existingReactionRoles) { - sendErrorMessage(pluginData, msg.channel, "Message doesn't have reaction roles on it"); + void pluginData.state.common.sendErrorMessage(msg, "Message doesn't have reaction roles on it"); return; } @@ -26,7 +25,7 @@ export const ClearReactionRolesCmd = reactionRolesCmd({ targetMessage = await args.message.channel.messages.fetch(args.message.messageId); } catch (err) { if (isDiscordAPIError(err) && err.code === 50001) { - sendErrorMessage(pluginData, msg.channel, "Missing access to the specified message"); + void pluginData.state.common.sendErrorMessage(msg, "Missing access to the specified message"); return; } @@ -35,6 +34,6 @@ export const ClearReactionRolesCmd = reactionRolesCmd({ await targetMessage.reactions.removeAll(); - sendSuccessMessage(pluginData, msg.channel, "Reaction roles cleared"); + void pluginData.state.common.sendSuccessMessage(msg, "Reaction roles cleared"); }, }); diff --git a/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts index 949e773b..e3144123 100644 --- a/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts +++ b/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts @@ -1,6 +1,5 @@ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { canUseEmoji, isDiscordAPIError, isValidEmoji, noop, trimPluginDescription } from "../../../utils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { TReactionRolePair, reactionRolesCmd } from "../types.js"; @@ -34,7 +33,10 @@ export const InitReactionRolesCmd = reactionRolesCmd({ async run({ message: msg, args, pluginData }) { if (!canReadChannel(args.message.channel, msg.member)) { - sendErrorMessage(pluginData, msg.channel, "You can't add reaction roles to channels you can't see yourself"); + void pluginData.state.common.sendErrorMessage( + msg, + "You can't add reaction roles to channels you can't see yourself", + ); return; } @@ -43,7 +45,7 @@ export const InitReactionRolesCmd = reactionRolesCmd({ targetMessage = await args.message.channel.messages.fetch(args.message.messageId); } catch (e) { if (isDiscordAPIError(e)) { - sendErrorMessage(pluginData, msg.channel, `Error ${e.code} while getting message: ${e.message}`); + void pluginData.state.common.sendErrorMessage(msg, `Error ${e.code} while getting message: ${e.message}`); return; } @@ -71,30 +73,28 @@ export const InitReactionRolesCmd = reactionRolesCmd({ // Verify the specified emojis and roles are valid and usable for (const pair of emojiRolePairs) { if (pair[0] === CLEAR_ROLES_EMOJI) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `The emoji for clearing roles (${CLEAR_ROLES_EMOJI}) is reserved and cannot be used`, ); return; } if (!isValidEmoji(pair[0])) { - sendErrorMessage(pluginData, msg.channel, `Invalid emoji: ${pair[0]}`); + void pluginData.state.common.sendErrorMessage(msg, `Invalid emoji: ${pair[0]}`); return; } if (!canUseEmoji(pluginData.client, pair[0])) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, "I can only use regular emojis and custom emojis from servers I'm on", ); return; } if (!pluginData.guild.roles.cache.has(pair[1] as Snowflake)) { - sendErrorMessage(pluginData, msg.channel, `Unknown role ${pair[1]}`); + void pluginData.state.common.sendErrorMessage(msg, `Unknown role ${pair[1]}`); return; } } @@ -125,9 +125,9 @@ export const InitReactionRolesCmd = reactionRolesCmd({ ); if (errors?.length) { - sendErrorMessage(pluginData, msg.channel, `Errors while adding reaction roles:\n${errors.join("\n")}`); + void pluginData.state.common.sendErrorMessage(msg, `Errors while adding reaction roles:\n${errors.join("\n")}`); } else { - sendSuccessMessage(pluginData, msg.channel, "Reaction roles added"); + void pluginData.state.common.sendSuccessMessage(msg, "Reaction roles added"); } (await progressMessage).delete().catch(noop); diff --git a/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts index f222e742..3dd6392a 100644 --- a/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts +++ b/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { reactionRolesCmd } from "../types.js"; import { refreshReactionRoles } from "../util/refreshReactionRoles.js"; @@ -13,12 +12,12 @@ export const RefreshReactionRolesCmd = reactionRolesCmd({ async run({ message: msg, args, pluginData }) { if (pluginData.state.pendingRefreshes.has(`${args.message.channel.id}-${args.message.messageId}`)) { - sendErrorMessage(pluginData, msg.channel, "Another refresh in progress"); + void pluginData.state.common.sendErrorMessage(msg, "Another refresh in progress"); return; } await refreshReactionRoles(pluginData, args.message.channel.id, args.message.messageId); - sendSuccessMessage(pluginData, msg.channel, "Reaction roles refreshed"); + void pluginData.state.common.sendSuccessMessage(msg, "Reaction roles refreshed"); }, }); diff --git a/backend/src/plugins/ReactionRoles/types.ts b/backend/src/plugins/ReactionRoles/types.ts index 5090d181..b5506c30 100644 --- a/backend/src/plugins/ReactionRoles/types.ts +++ b/backend/src/plugins/ReactionRoles/types.ts @@ -1,8 +1,9 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { Queue } from "../../Queue.js"; import { GuildReactionRoles } from "../../data/GuildReactionRoles.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zReactionRolesConfig = z.strictObject({ auto_refresh_interval: z.number(), @@ -37,6 +38,8 @@ export interface ReactionRolesPluginType extends BasePluginType { pendingRefreshes: Set; autoRefreshTimeout: NodeJS.Timeout; + + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/Reminders/RemindersPlugin.ts b/backend/src/plugins/Reminders/RemindersPlugin.ts index dc317bd7..439d7460 100644 --- a/backend/src/plugins/Reminders/RemindersPlugin.ts +++ b/backend/src/plugins/Reminders/RemindersPlugin.ts @@ -1,6 +1,7 @@ import { PluginOptions, guildPlugin } from "knub"; import { onGuildEvent } from "../../data/GuildEvents.js"; import { GuildReminders } from "../../data/GuildReminders.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { RemindCmd } from "./commands/RemindCmd.js"; import { RemindersCmd } from "./commands/RemindersCmd.js"; @@ -44,6 +45,10 @@ export const RemindersPlugin = guildPlugin()({ state.unloaded = false; }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/Reminders/commands/RemindCmd.ts b/backend/src/plugins/Reminders/commands/RemindCmd.ts index 136a5c30..eaa66746 100644 --- a/backend/src/plugins/Reminders/commands/RemindCmd.ts +++ b/backend/src/plugins/Reminders/commands/RemindCmd.ts @@ -2,7 +2,6 @@ import humanizeDuration from "humanize-duration"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { registerUpcomingReminder } from "../../../data/loops/upcomingRemindersLoop.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { convertDelayStringToMS, messageLink } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { remindersCmd } from "../types.js"; @@ -38,7 +37,7 @@ export const RemindCmd = remindersCmd({ // "Delay string" i.e. e.g. "2h30m" const ms = convertDelayStringToMS(args.time); if (ms === null) { - sendErrorMessage(pluginData, msg.channel, "Invalid reminder time"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid reminder time"); return; } @@ -46,7 +45,7 @@ export const RemindCmd = remindersCmd({ } if (!reminderTime.isValid() || reminderTime.isBefore(now)) { - sendErrorMessage(pluginData, msg.channel, "Invalid reminder time"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid reminder time"); return; } @@ -67,9 +66,8 @@ export const RemindCmd = remindersCmd({ pluginData.getPlugin(TimeAndDatePlugin).getDateFormat("pretty_datetime"), ); - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `I will remind you in **${timeUntilReminder}** at **${prettyReminderTime}**`, ); }, diff --git a/backend/src/plugins/Reminders/commands/RemindersCmd.ts b/backend/src/plugins/Reminders/commands/RemindersCmd.ts index 04ef4125..585557ab 100644 --- a/backend/src/plugins/Reminders/commands/RemindersCmd.ts +++ b/backend/src/plugins/Reminders/commands/RemindersCmd.ts @@ -1,6 +1,5 @@ import humanizeDuration from "humanize-duration"; import moment from "moment-timezone"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { createChunkedMessage, DBDateFormat, sorter } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { remindersCmd } from "../types.js"; @@ -12,7 +11,7 @@ export const RemindersCmd = remindersCmd({ async run({ message: msg, pluginData }) { const reminders = await pluginData.state.reminders.getRemindersByUserId(msg.author.id); if (reminders.length === 0) { - sendErrorMessage(pluginData, msg.channel, "No reminders"); + void pluginData.state.common.sendErrorMessage(msg, "No reminders"); return; } diff --git a/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts b/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts index bd53a8f4..a1eb5533 100644 --- a/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts +++ b/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts @@ -1,6 +1,5 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { clearUpcomingReminder } from "../../../data/loops/upcomingRemindersLoop.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { sorter } from "../../../utils.js"; import { remindersCmd } from "../types.js"; @@ -17,7 +16,7 @@ export const RemindersDeleteCmd = remindersCmd({ reminders.sort(sorter("remind_at")); if (args.num > reminders.length || args.num <= 0) { - sendErrorMessage(pluginData, msg.channel, "Unknown reminder"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown reminder"); return; } @@ -25,6 +24,6 @@ export const RemindersDeleteCmd = remindersCmd({ clearUpcomingReminder(toDelete); await pluginData.state.reminders.delete(toDelete.id); - sendSuccessMessage(pluginData, msg.channel, "Reminder deleted"); + void pluginData.state.common.sendSuccessMessage(msg, "Reminder deleted"); }, }); diff --git a/backend/src/plugins/Reminders/types.ts b/backend/src/plugins/Reminders/types.ts index ae0daf24..d3b3c05f 100644 --- a/backend/src/plugins/Reminders/types.ts +++ b/backend/src/plugins/Reminders/types.ts @@ -1,6 +1,7 @@ -import { BasePluginType, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { GuildReminders } from "../../data/GuildReminders.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zRemindersConfig = z.strictObject({ can_use: z.boolean(), @@ -12,6 +13,7 @@ export interface RemindersPluginType extends BasePluginType { state: { reminders: GuildReminders; tries: Map; + common: pluginUtils.PluginPublicInterface; unregisterGuildEventListener: () => void; diff --git a/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts index 44b537b0..f7d9f581 100644 --- a/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts +++ b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts @@ -1,5 +1,6 @@ import { guildPlugin } from "knub"; import { GuildRoleButtons } from "../../data/GuildRoleButtons.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin.js"; import { resetButtonsCmd } from "./commands/resetButtons.js"; @@ -37,6 +38,10 @@ export const RoleButtonsPlugin = guildPlugin()({ pluginData.state.roleButtons = GuildRoleButtons.getGuildInstance(pluginData.guild.id); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + async afterLoad(pluginData) { await applyAllRoleButtons(pluginData); }, diff --git a/backend/src/plugins/RoleButtons/commands/resetButtons.ts b/backend/src/plugins/RoleButtons/commands/resetButtons.ts index 122820d4..6213e4d5 100644 --- a/backend/src/plugins/RoleButtons/commands/resetButtons.ts +++ b/backend/src/plugins/RoleButtons/commands/resetButtons.ts @@ -1,6 +1,5 @@ import { guildPluginMessageCommand } from "knub"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { applyAllRoleButtons } from "../functions/applyAllRoleButtons.js"; import { RoleButtonsPluginType } from "../types.js"; @@ -16,12 +15,12 @@ export const resetButtonsCmd = guildPluginMessageCommand( async run({ pluginData, args, message }) { const config = pluginData.config.get(); if (!config.buttons[args.name]) { - sendErrorMessage(pluginData, message.channel, `Can't find role buttons with the name "${args.name}"`); + void pluginData.state.common.sendErrorMessage(message, `Can't find role buttons with the name "${args.name}"`); return; } await pluginData.state.roleButtons.deleteRoleButtonItem(args.name); await applyAllRoleButtons(pluginData); - sendSuccessMessage(pluginData, message.channel, "Done!"); + void pluginData.state.common.sendSuccessMessage(message, "Done!"); }, }); diff --git a/backend/src/plugins/RoleButtons/types.ts b/backend/src/plugins/RoleButtons/types.ts index 142b82e7..37d84550 100644 --- a/backend/src/plugins/RoleButtons/types.ts +++ b/backend/src/plugins/RoleButtons/types.ts @@ -1,8 +1,9 @@ import { ButtonStyle } from "discord.js"; -import { BasePluginType } from "knub"; +import { BasePluginType, pluginUtils } from "knub"; import z from "zod"; import { GuildRoleButtons } from "../../data/GuildRoleButtons.js"; import { zBoundedCharacters, zBoundedRecord, zMessageContent, zSnowflake } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { TooManyComponentsError } from "./functions/TooManyComponentsError.js"; import { createButtonComponents } from "./functions/createButtonComponents.js"; @@ -109,5 +110,6 @@ export interface RoleButtonsPluginType extends BasePluginType { config: z.infer; state: { roleButtons: GuildRoleButtons; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/Roles/RolesPlugin.ts b/backend/src/plugins/Roles/RolesPlugin.ts index cfd94ee2..2334280b 100644 --- a/backend/src/plugins/Roles/RolesPlugin.ts +++ b/backend/src/plugins/Roles/RolesPlugin.ts @@ -1,5 +1,6 @@ import { PluginOptions, guildPlugin } from "knub"; import { GuildLogs } from "../../data/GuildLogs.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin.js"; import { AddRoleCmd } from "./commands/AddRoleCmd.js"; @@ -50,4 +51,8 @@ export const RolesPlugin = guildPlugin()({ state.logs = new GuildLogs(guild.id); }, + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/Roles/commands/AddRoleCmd.ts b/backend/src/plugins/Roles/commands/AddRoleCmd.ts index 9581e087..d984082f 100644 --- a/backend/src/plugins/Roles/commands/AddRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/AddRoleCmd.ts @@ -1,6 +1,6 @@ import { GuildChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { canActOn } from "../../../pluginUtils.js"; import { resolveRoleId, verboseUserMention } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; @@ -18,19 +18,19 @@ export const AddRoleCmd = rolesCmd({ async run({ message: msg, args, pluginData }) { if (!canActOn(pluginData, msg.member, args.member, true)) { - sendErrorMessage(pluginData, msg.channel, "Cannot add roles to this user: insufficient permissions"); + void pluginData.state.common.sendErrorMessage(msg, "Cannot add roles to this user: insufficient permissions"); return; } const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { - sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid role id"); return; } const config = await pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { - sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot assign that role"); return; } @@ -40,12 +40,12 @@ export const AddRoleCmd = rolesCmd({ pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); - sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot assign that role"); return; } if (args.member.roles.cache.has(roleId)) { - sendErrorMessage(pluginData, msg.channel, "Member already has that role"); + void pluginData.state.common.sendErrorMessage(msg, "Member already has that role"); return; } @@ -57,9 +57,8 @@ export const AddRoleCmd = rolesCmd({ roles: [role], }); - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Added role **${role.name}** to ${verboseUserMention(args.member.user)}!`, ); }, diff --git a/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts b/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts index ae7f8b8f..6d008f32 100644 --- a/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts @@ -1,7 +1,7 @@ import { GuildMember } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { logger } from "../../../logger.js"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils.js"; +import { canActOn } from "../../../pluginUtils.js"; import { resolveMember, resolveRoleId, successMessage } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; @@ -29,9 +29,8 @@ export const MassAddRoleCmd = rolesCmd({ for (const member of members) { if (!canActOn(pluginData, msg.member, member, true)) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, "Cannot add roles to 1 or more specified members: insufficient permissions", ); return; @@ -40,13 +39,13 @@ export const MassAddRoleCmd = rolesCmd({ const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { - sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid role id"); return; } const config = await pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { - sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot assign that role"); return; } @@ -55,7 +54,7 @@ export const MassAddRoleCmd = rolesCmd({ pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); - sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot assign that role"); return; } diff --git a/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts b/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts index f00c7813..169cf413 100644 --- a/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts @@ -1,6 +1,6 @@ import { GuildMember } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils.js"; +import { canActOn } from "../../../pluginUtils.js"; import { resolveMember, resolveRoleId, successMessage } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; @@ -28,9 +28,8 @@ export const MassRemoveRoleCmd = rolesCmd({ for (const member of members) { if (!canActOn(pluginData, msg.member, member, true)) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, "Cannot add roles to 1 or more specified members: insufficient permissions", ); return; @@ -39,13 +38,13 @@ export const MassRemoveRoleCmd = rolesCmd({ const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { - sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid role id"); return; } const config = await pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { - sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot remove that role"); return; } @@ -54,7 +53,7 @@ export const MassRemoveRoleCmd = rolesCmd({ pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); - sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot remove that role"); return; } diff --git a/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts b/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts index 2677be8c..1a4a4202 100644 --- a/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts @@ -1,6 +1,6 @@ import { GuildChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { canActOn } from "../../../pluginUtils.js"; import { resolveRoleId, verboseUserMention } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; @@ -18,19 +18,22 @@ export const RemoveRoleCmd = rolesCmd({ async run({ message: msg, args, pluginData }) { if (!canActOn(pluginData, msg.member, args.member, true)) { - sendErrorMessage(pluginData, msg.channel, "Cannot remove roles from this user: insufficient permissions"); + void pluginData.state.common.sendErrorMessage( + msg, + "Cannot remove roles from this user: insufficient permissions", + ); return; } const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { - sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid role id"); return; } const config = await pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { - sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot remove that role"); return; } @@ -40,12 +43,12 @@ export const RemoveRoleCmd = rolesCmd({ pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); - sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot remove that role"); return; } if (!args.member.roles.cache.has(roleId)) { - sendErrorMessage(pluginData, msg.channel, "Member doesn't have that role"); + void pluginData.state.common.sendErrorMessage(msg, "Member doesn't have that role"); return; } @@ -56,9 +59,8 @@ export const RemoveRoleCmd = rolesCmd({ roles: [role], }); - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Removed role **${role.name}** from ${verboseUserMention(args.member.user)}!`, ); }, diff --git a/backend/src/plugins/Roles/types.ts b/backend/src/plugins/Roles/types.ts index 2a0d4cc7..557a1d86 100644 --- a/backend/src/plugins/Roles/types.ts +++ b/backend/src/plugins/Roles/types.ts @@ -1,6 +1,7 @@ -import { BasePluginType, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { GuildLogs } from "../../data/GuildLogs.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zRolesConfig = z.strictObject({ can_assign: z.boolean(), @@ -12,6 +13,7 @@ export interface RolesPluginType extends BasePluginType { config: z.infer; state: { logs: GuildLogs; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts index e01b2f8f..b97e0857 100644 --- a/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts +++ b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts @@ -1,4 +1,5 @@ import { CooldownManager, PluginOptions, guildPlugin } from "knub"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { RoleAddCmd } from "./commands/RoleAddCmd.js"; import { RoleHelpCmd } from "./commands/RoleHelpCmd.js"; import { RoleRemoveCmd } from "./commands/RoleRemoveCmd.js"; @@ -27,4 +28,8 @@ export const SelfGrantableRolesPlugin = guildPlugin Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`, - { users: [msg.author.id] }, + { + users: [msg.author.id], + }, ); lock.unlock(); return; @@ -84,11 +84,12 @@ export const RoleAddCmd = selfGrantableRolesCmd({ roles: Array.from(newRoleIds) as Snowflake[], }); } catch { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `<@!${msg.author.id}> Got an error while trying to grant you the roles`, - { users: [msg.author.id] }, + { + users: [msg.author.id], + }, ); return; } @@ -120,7 +121,7 @@ export const RoleAddCmd = selfGrantableRolesCmd({ messageParts.push("couldn't recognize some of the roles"); } - sendSuccessMessage(pluginData, msg.channel, `<@!${msg.author.id}> ${messageParts.join("; ")}`, { + void pluginData.state.common.sendSuccessMessage(msg, `<@!${msg.author.id}> ${messageParts.join("; ")}`, { users: [msg.author.id], }); diff --git a/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts b/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts index aaec79e8..b6e8d8ff 100644 --- a/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts +++ b/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts @@ -1,6 +1,5 @@ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { memberRolesLock } from "../../../utils/lockNameHelpers.js"; import { selfGrantableRolesCmd } from "../types.js"; import { findMatchingRoles } from "../util/findMatchingRoles.js"; @@ -46,35 +45,37 @@ export const RoleRemoveCmd = selfGrantableRolesCmd({ const removedRolesWord = rolesToRemove.length === 1 ? "role" : "roles"; if (rolesToRemove.length !== roleNames.length) { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord};` + ` couldn't recognize the other roles you mentioned`, { users: [msg.author.id] }, ); } else { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord}`, - { users: [msg.author.id] }, + { + users: [msg.author.id], + }, ); } } catch { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `<@!${msg.author.id}> Got an error while trying to remove the roles`, - { users: [msg.author.id] }, + { + users: [msg.author.id], + }, ); } } else { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`, - { users: [msg.author.id] }, + { + users: [msg.author.id], + }, ); } diff --git a/backend/src/plugins/SelfGrantableRoles/types.ts b/backend/src/plugins/SelfGrantableRoles/types.ts index edfb7e1c..891672e9 100644 --- a/backend/src/plugins/SelfGrantableRoles/types.ts +++ b/backend/src/plugins/SelfGrantableRoles/types.ts @@ -1,6 +1,7 @@ -import { BasePluginType, CooldownManager, guildPluginMessageCommand } from "knub"; +import { BasePluginType, CooldownManager, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { zBoundedCharacters, zBoundedRecord } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; const zRoleMap = z.record( zBoundedCharacters(1, 100), @@ -27,6 +28,7 @@ export interface SelfGrantableRolesPluginType extends BasePluginType { config: z.infer; state: { cooldowns: CooldownManager; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/Slowmode/SlowmodePlugin.ts b/backend/src/plugins/Slowmode/SlowmodePlugin.ts index 34d06699..4b80e2e1 100644 --- a/backend/src/plugins/Slowmode/SlowmodePlugin.ts +++ b/backend/src/plugins/Slowmode/SlowmodePlugin.ts @@ -3,6 +3,7 @@ import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildSlowmodes } from "../../data/GuildSlowmodes.js"; import { SECONDS } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { SlowmodeClearCmd } from "./commands/SlowmodeClearCmd.js"; import { SlowmodeDisableCmd } from "./commands/SlowmodeDisableCmd.js"; @@ -63,6 +64,10 @@ export const SlowmodePlugin = guildPlugin()({ state.channelSlowmodeCache = new Map(); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state } = pluginData; diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts index cf2cff3d..e2b4569f 100644 --- a/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts +++ b/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts @@ -1,6 +1,5 @@ import { ChannelType, escapeInlineCode } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { asSingleLine, renderUsername } from "../../../utils.js"; import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; @@ -22,16 +21,15 @@ export const SlowmodeClearCmd = slowmodeCmd({ async run({ message: msg, args, pluginData }) { const channelSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(args.channel.id); if (!channelSlowmode) { - sendErrorMessage(pluginData, msg.channel, "Channel doesn't have slowmode!"); + void pluginData.state.common.sendErrorMessage(msg, "Channel doesn't have slowmode!"); return; } const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; const missingPermissions = getMissingChannelPermissions(me, args.channel, BOT_SLOWMODE_CLEAR_PERMISSIONS); if (missingPermissions) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `Unable to clear slowmode. ${missingPermissionError(missingPermissions)}`, ); return; @@ -41,9 +39,8 @@ export const SlowmodeClearCmd = slowmodeCmd({ if (args.channel.type === ChannelType.GuildText) { await clearBotSlowmodeFromUserId(pluginData, args.channel, args.user.id, args.force); } else { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, asSingleLine(` Failed to clear slowmode from **${renderUsername(args.user)}** in <#${args.channel.id}>: Threads cannot have Bot Slowmode @@ -52,9 +49,8 @@ export const SlowmodeClearCmd = slowmodeCmd({ return; } } catch (e) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, asSingleLine(` Failed to clear slowmode from **${renderUsername(args.user)}** in <#${args.channel.id}>: \`${escapeInlineCode(e.message)}\` @@ -63,9 +59,8 @@ export const SlowmodeClearCmd = slowmodeCmd({ return; } - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Slowmode cleared from **${renderUsername(args.user)}** in <#${args.channel.id}>`, ); }, diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts index 82ebec82..52a5713e 100644 --- a/backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts +++ b/backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts @@ -1,7 +1,6 @@ import { escapeInlineCode, PermissionsBitField } from "discord.js"; import humanizeDuration from "humanize-duration"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { asSingleLine, DAYS, HOURS, MINUTES } from "../../../utils.js"; import { getMissingPermissions } from "../../../utils/getMissingPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; @@ -40,7 +39,7 @@ export const SlowmodeSetCmd = slowmodeCmd({ const channel = args.channel || msg.channel; if (!channel.isTextBased() || channel.isThread()) { - sendErrorMessage(pluginData, msg.channel, "Slowmode can only be set on non-thread text-based channels"); + void pluginData.state.common.sendErrorMessage(msg, "Slowmode can only be set on non-thread text-based channels"); return; } @@ -56,29 +55,27 @@ export const SlowmodeSetCmd = slowmodeCmd({ const mode = (args.mode as TMode) || defaultMode; if (!validModes.includes(mode)) { - sendErrorMessage(pluginData, msg.channel, "--mode must be 'bot' or 'native'"); + void pluginData.state.common.sendErrorMessage(msg, "--mode must be 'bot' or 'native'"); return; } // Validate durations if (mode === "native" && args.time > MAX_NATIVE_SLOWMODE) { - sendErrorMessage(pluginData, msg.channel, "Native slowmode can only be set to 6h or less"); + void pluginData.state.common.sendErrorMessage(msg, "Native slowmode can only be set to 6h or less"); return; } if (mode === "bot" && args.time > MAX_BOT_SLOWMODE) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `Sorry, bot managed slowmodes can be at most 100 years long. Maybe 99 would be enough?`, ); return; } if (mode === "bot" && args.time < MIN_BOT_SLOWMODE) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, asSingleLine(` Bot managed slowmode must be 15min or more. Use \`--mode native\` to use native slowmodes for short slowmodes instead. @@ -96,9 +93,8 @@ export const SlowmodeSetCmd = slowmodeCmd({ NATIVE_SLOWMODE_PERMISSIONS, ); if (missingPermissions) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `Unable to set native slowmode. ${missingPermissionError(missingPermissions)}`, ); return; @@ -111,9 +107,8 @@ export const SlowmodeSetCmd = slowmodeCmd({ BOT_SLOWMODE_PERMISSIONS, ); if (missingPermissions) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `Unable to set bot managed slowmode. ${missingPermissionError(missingPermissions)}`, ); return; @@ -134,7 +129,10 @@ export const SlowmodeSetCmd = slowmodeCmd({ try { await channel.setRateLimitPerUser(rateLimitSeconds); } catch (e) { - sendErrorMessage(pluginData, msg.channel, `Failed to set native slowmode: ${escapeInlineCode(e.message)}`); + void pluginData.state.common.sendErrorMessage( + msg, + `Failed to set native slowmode: ${escapeInlineCode(e.message)}`, + ); return; } } else { @@ -153,9 +151,8 @@ export const SlowmodeSetCmd = slowmodeCmd({ const humanizedSlowmodeTime = humanizeDuration(args.time); const slowmodeType = mode === "native" ? "native slowmode" : "bot-maintained slowmode"; - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Set ${humanizedSlowmodeTime} slowmode for <#${channel.id}> (${slowmodeType})`, ); }, diff --git a/backend/src/plugins/Slowmode/types.ts b/backend/src/plugins/Slowmode/types.ts index acdd611a..a8ea070b 100644 --- a/backend/src/plugins/Slowmode/types.ts +++ b/backend/src/plugins/Slowmode/types.ts @@ -1,9 +1,10 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildSlowmodes } from "../../data/GuildSlowmodes.js"; import { SlowmodeChannel } from "../../data/entities/SlowmodeChannel.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zSlowmodeConfig = z.strictObject({ use_native_slowmode: z.boolean(), @@ -21,6 +22,7 @@ export interface SlowmodePluginType extends BasePluginType { clearInterval: NodeJS.Timeout; serverLogs: GuildLogs; channelSlowmodeCache: Map; + common: pluginUtils.PluginPublicInterface; onMessageCreateFn; }; diff --git a/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts b/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts index d669f05a..4c26e812 100644 --- a/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts +++ b/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts @@ -1,5 +1,4 @@ import { Message } from "discord.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { noop } from "../../../utils.js"; import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; @@ -11,16 +10,15 @@ export async function actualDisableSlowmodeCmd(msg: Message, args, pluginData) { const hasNativeSlowmode = args.channel.rateLimitPerUser; if (!botSlowmode && hasNativeSlowmode === 0) { - sendErrorMessage(pluginData, msg.channel, "Channel is not on slowmode!"); + void pluginData.state.common.sendErrorMessage(msg, "Channel is not on slowmode!"); return; } const me = pluginData.guild.members.cache.get(pluginData.client.user!.id); const missingPermissions = getMissingChannelPermissions(me, args.channel, BOT_SLOWMODE_DISABLE_PERMISSIONS); if (missingPermissions) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `Unable to disable slowmode. ${missingPermissionError(missingPermissions)}`, ); return; @@ -41,13 +39,12 @@ export async function actualDisableSlowmodeCmd(msg: Message, args, pluginData) { } if (failedUsers.length) { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Slowmode disabled! Failed to clear slowmode from the following users:\n\n<@!${failedUsers.join(">\n<@!")}>`, ); } else { - sendSuccessMessage(pluginData, msg.channel, "Slowmode disabled!"); + void pluginData.state.common.sendSuccessMessage(msg, "Slowmode disabled!"); initMsg.delete().catch(noop); } } diff --git a/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts b/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts index cbdbc8a9..56a06067 100644 --- a/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts +++ b/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts @@ -76,10 +76,13 @@ export async function logAndDetectMessageSpam( (spamConfig.mute_time && convertDelayStringToMS(spamConfig.mute_time.toString())) ?? 120 * 1000; try { + const reason = "Automatic spam detection"; + muteResult = await mutesPlugin.muteUser( member.id, muteTime, - "Automatic spam detection", + reason, + reason, { caseArgs: { modId: pluginData.client.user!.id, diff --git a/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts b/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts index bb82af68..6f0a2744 100644 --- a/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts +++ b/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts @@ -40,10 +40,13 @@ export async function logAndDetectOtherSpam( (spamConfig.mute_time && convertDelayStringToMS(spamConfig.mute_time.toString())) ?? 120 * 1000; try { + const reason = "Automatic spam detection"; + await mutesPlugin.muteUser( member.id, muteTime, - "Automatic spam detection", + reason, + reason, { caseArgs: { modId: pluginData.client.user!.id, diff --git a/backend/src/plugins/Starboard/StarboardPlugin.ts b/backend/src/plugins/Starboard/StarboardPlugin.ts index 17d6a051..474a2be2 100644 --- a/backend/src/plugins/Starboard/StarboardPlugin.ts +++ b/backend/src/plugins/Starboard/StarboardPlugin.ts @@ -2,6 +2,7 @@ import { PluginOptions, guildPlugin } from "knub"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildStarboardMessages } from "../../data/GuildStarboardMessages.js"; import { GuildStarboardReactions } from "../../data/GuildStarboardReactions.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { MigratePinsCmd } from "./commands/MigratePinsCmd.js"; import { StarboardReactionAddEvt } from "./events/StarboardReactionAddEvt.js"; import { StarboardReactionRemoveAllEvt, StarboardReactionRemoveEvt } from "./events/StarboardReactionRemoveEvts.js"; @@ -50,6 +51,10 @@ export const StarboardPlugin = guildPlugin()({ state.starboardReactions = GuildStarboardReactions.getGuildInstance(guild.id); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state } = pluginData; diff --git a/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts b/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts index 66e70234..f2971aa0 100644 --- a/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts +++ b/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts @@ -1,6 +1,5 @@ import { Snowflake, TextChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { starboardCmd } from "../types.js"; import { saveMessageToStarboard } from "../util/saveMessageToStarboard.js"; @@ -19,13 +18,13 @@ export const MigratePinsCmd = starboardCmd({ const config = await pluginData.config.get(); const starboard = config.boards[args.starboardName]; if (!starboard) { - sendErrorMessage(pluginData, msg.channel, "Unknown starboard specified"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown starboard specified"); return; } const starboardChannel = pluginData.guild.channels.cache.get(starboard.channel_id as Snowflake); if (!starboardChannel || !(starboardChannel instanceof TextChannel)) { - sendErrorMessage(pluginData, msg.channel, "Starboard has an unknown/invalid channel id"); + void pluginData.state.common.sendErrorMessage(msg, "Starboard has an unknown/invalid channel id"); return; } @@ -43,9 +42,8 @@ export const MigratePinsCmd = starboardCmd({ await saveMessageToStarboard(pluginData, pin, starboard); } - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`, ); }, diff --git a/backend/src/plugins/Starboard/types.ts b/backend/src/plugins/Starboard/types.ts index 7ab61559..336cd297 100644 --- a/backend/src/plugins/Starboard/types.ts +++ b/backend/src/plugins/Starboard/types.ts @@ -1,9 +1,10 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildStarboardMessages } from "../../data/GuildStarboardMessages.js"; import { GuildStarboardReactions } from "../../data/GuildStarboardReactions.js"; import { zBoundedRecord, zSnowflake } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; const zStarboardOpts = z.strictObject({ channel_id: zSnowflake, @@ -29,6 +30,7 @@ export interface StarboardPluginType extends BasePluginType { savedMessages: GuildSavedMessages; starboardMessages: GuildStarboardMessages; starboardReactions: GuildStarboardReactions; + common: pluginUtils.PluginPublicInterface; onMessageDeleteFn; }; diff --git a/backend/src/plugins/Tags/TagsPlugin.ts b/backend/src/plugins/Tags/TagsPlugin.ts index 303a13dc..f1ded1d3 100644 --- a/backend/src/plugins/Tags/TagsPlugin.ts +++ b/backend/src/plugins/Tags/TagsPlugin.ts @@ -8,6 +8,7 @@ import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildTags } from "../../data/GuildTags.js"; import { makePublicFn } from "../../pluginUtils.js"; import { convertDelayStringToMS } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { TagCreateCmd } from "./commands/TagCreateCmd.js"; @@ -91,6 +92,10 @@ export const TagsPlugin = guildPlugin()({ state.tagFunctions = {}; }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state } = pluginData; diff --git a/backend/src/plugins/Tags/commands/TagCreateCmd.ts b/backend/src/plugins/Tags/commands/TagCreateCmd.ts index 64ed2486..c1a2d3cc 100644 --- a/backend/src/plugins/Tags/commands/TagCreateCmd.ts +++ b/backend/src/plugins/Tags/commands/TagCreateCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { TemplateParseError, parseTemplate } from "../../../templateFormatter.js"; import { tagsCmd } from "../types.js"; @@ -17,7 +16,7 @@ export const TagCreateCmd = tagsCmd({ parseTemplate(args.body); } catch (e) { if (e instanceof TemplateParseError) { - sendErrorMessage(pluginData, msg.channel, `Invalid tag syntax: ${e.message}`); + void pluginData.state.common.sendErrorMessage(msg, `Invalid tag syntax: ${e.message}`); return; } else { throw e; @@ -27,6 +26,6 @@ export const TagCreateCmd = tagsCmd({ await pluginData.state.tags.createOrUpdate(args.tag, args.body, msg.author.id); const prefix = pluginData.config.get().prefix; - sendSuccessMessage(pluginData, msg.channel, `Tag set! Use it with: \`${prefix}${args.tag}\``); + void pluginData.state.common.sendSuccessMessage(msg, `Tag set! Use it with: \`${prefix}${args.tag}\``); }, }); diff --git a/backend/src/plugins/Tags/commands/TagDeleteCmd.ts b/backend/src/plugins/Tags/commands/TagDeleteCmd.ts index 0a711c76..a0f1f0ea 100644 --- a/backend/src/plugins/Tags/commands/TagDeleteCmd.ts +++ b/backend/src/plugins/Tags/commands/TagDeleteCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { tagsCmd } from "../types.js"; export const TagDeleteCmd = tagsCmd({ @@ -13,11 +12,11 @@ export const TagDeleteCmd = tagsCmd({ async run({ message: msg, args, pluginData }) { const tag = await pluginData.state.tags.find(args.tag); if (!tag) { - sendErrorMessage(pluginData, msg.channel, "No tag with that name"); + void pluginData.state.common.sendErrorMessage(msg, "No tag with that name"); return; } await pluginData.state.tags.delete(args.tag); - sendSuccessMessage(pluginData, msg.channel, "Tag deleted!"); + void pluginData.state.common.sendSuccessMessage(msg, "Tag deleted!"); }, }); diff --git a/backend/src/plugins/Tags/commands/TagEvalCmd.ts b/backend/src/plugins/Tags/commands/TagEvalCmd.ts index 4af424a0..204f5e8c 100644 --- a/backend/src/plugins/Tags/commands/TagEvalCmd.ts +++ b/backend/src/plugins/Tags/commands/TagEvalCmd.ts @@ -1,7 +1,6 @@ import { MessageCreateOptions } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { logger } from "../../../logger.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { TemplateParseError } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { tagsCmd } from "../types.js"; @@ -29,7 +28,7 @@ export const TagEvalCmd = tagsCmd({ )) as MessageCreateOptions; if (!rendered.content && !rendered.embeds?.length) { - sendErrorMessage(pluginData, msg.channel, "Evaluation resulted in an empty text"); + void pluginData.state.common.sendErrorMessage(msg, "Evaluation resulted in an empty text"); return; } @@ -37,7 +36,7 @@ export const TagEvalCmd = tagsCmd({ } catch (e) { const errorMessage = e instanceof TemplateParseError ? e.message : "Internal error"; - sendErrorMessage(pluginData, msg.channel, `Failed to render tag: ${errorMessage}`); + void pluginData.state.common.sendErrorMessage(msg, `Failed to render tag: ${errorMessage}`); if (!(e instanceof TemplateParseError)) { logger.warn(`Internal error evaluating tag in ${pluginData.guild.id}: ${e}`); diff --git a/backend/src/plugins/Tags/commands/TagSourceCmd.ts b/backend/src/plugins/Tags/commands/TagSourceCmd.ts index 58729226..92aaff68 100644 --- a/backend/src/plugins/Tags/commands/TagSourceCmd.ts +++ b/backend/src/plugins/Tags/commands/TagSourceCmd.ts @@ -1,6 +1,6 @@ import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { getBaseUrl, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { getBaseUrl } from "../../../pluginUtils.js"; import { tagsCmd } from "../types.js"; export const TagSourceCmd = tagsCmd({ @@ -17,18 +17,18 @@ export const TagSourceCmd = tagsCmd({ if (args.delete) { const actualTag = await pluginData.state.tags.find(args.tag); if (!actualTag) { - sendErrorMessage(pluginData, msg.channel, "No tag with that name"); + void pluginData.state.common.sendErrorMessage(msg, "No tag with that name"); return; } await pluginData.state.tags.delete(args.tag); - sendSuccessMessage(pluginData, msg.channel, "Tag deleted!"); + void pluginData.state.common.sendSuccessMessage(msg, "Tag deleted!"); return; } const tag = await pluginData.state.tags.find(args.tag); if (!tag) { - sendErrorMessage(pluginData, msg.channel, "No tag with that name"); + void pluginData.state.common.sendErrorMessage(msg, "No tag with that name"); return; } diff --git a/backend/src/plugins/Tags/types.ts b/backend/src/plugins/Tags/types.ts index 3043c3b7..6a730e75 100644 --- a/backend/src/plugins/Tags/types.ts +++ b/backend/src/plugins/Tags/types.ts @@ -1,12 +1,13 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildTags } from "../../data/GuildTags.js"; -import { zEmbedInput } from "../../utils.js"; +import { zBoundedCharacters, zStrictMessageContent } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; -export const zTag = z.union([z.string(), zEmbedInput]); +export const zTag = z.union([zBoundedCharacters(0, 4000), zStrictMessageContent]); export type TTag = z.infer; export const zTagCategory = z @@ -59,6 +60,7 @@ export interface TagsPluginType extends BasePluginType { tags: GuildTags; savedMessages: GuildSavedMessages; logs: GuildLogs; + common: pluginUtils.PluginPublicInterface; onMessageCreateFn; diff --git a/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts b/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts index 4f83b65d..7de26655 100644 --- a/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts +++ b/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts @@ -1,6 +1,7 @@ import { PluginOptions, guildPlugin } from "knub"; import { GuildMemberTimezones } from "../../data/GuildMemberTimezones.js"; import { makePublicFn } from "../../pluginUtils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { ResetTimezoneCmd } from "./commands/ResetTimezoneCmd.js"; import { SetTimezoneCmd } from "./commands/SetTimezoneCmd.js"; import { ViewTimezoneCmd } from "./commands/ViewTimezoneCmd.js"; @@ -57,4 +58,8 @@ export const TimeAndDatePlugin = guildPlugin()({ state.memberTimezones = GuildMemberTimezones.getGuildInstance(guild.id); }, + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/TimeAndDate/commands/ResetTimezoneCmd.ts b/backend/src/plugins/TimeAndDate/commands/ResetTimezoneCmd.ts index 665161a8..fa5a41a0 100644 --- a/backend/src/plugins/TimeAndDate/commands/ResetTimezoneCmd.ts +++ b/backend/src/plugins/TimeAndDate/commands/ResetTimezoneCmd.ts @@ -1,4 +1,3 @@ -import { sendSuccessMessage } from "../../../pluginUtils.js"; import { getGuildTz } from "../functions/getGuildTz.js"; import { timeAndDateCmd } from "../types.js"; @@ -11,9 +10,8 @@ export const ResetTimezoneCmd = timeAndDateCmd({ async run({ pluginData, message }) { await pluginData.state.memberTimezones.reset(message.author.id); const serverTimezone = getGuildTz(pluginData); - sendSuccessMessage( - pluginData, - message.channel, + void pluginData.state.common.sendSuccessMessage( + message, `Your timezone has been reset to server default, **${serverTimezone}**`, ); }, diff --git a/backend/src/plugins/TimeAndDate/commands/SetTimezoneCmd.ts b/backend/src/plugins/TimeAndDate/commands/SetTimezoneCmd.ts index ef316fb8..f15546e5 100644 --- a/backend/src/plugins/TimeAndDate/commands/SetTimezoneCmd.ts +++ b/backend/src/plugins/TimeAndDate/commands/SetTimezoneCmd.ts @@ -1,6 +1,5 @@ import { escapeInlineCode } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { trimLines } from "../../../utils.js"; import { parseFuzzyTimezone } from "../../../utils/parseFuzzyTimezone.js"; import { timeAndDateCmd } from "../types.js"; @@ -16,9 +15,8 @@ export const SetTimezoneCmd = timeAndDateCmd({ async run({ pluginData, message, args }) { const parsedTz = parseFuzzyTimezone(args.timezone); if (!parsedTz) { - sendErrorMessage( - pluginData, - message.channel, + void pluginData.state.common.sendErrorMessage( + message, trimLines(` Invalid timezone: \`${escapeInlineCode(args.timezone)}\` Zeppelin uses timezone locations rather than specific timezone names. @@ -29,6 +27,6 @@ export const SetTimezoneCmd = timeAndDateCmd({ } await pluginData.state.memberTimezones.set(message.author.id, parsedTz); - sendSuccessMessage(pluginData, message.channel, `Your timezone is now set to **${parsedTz}**`); + void pluginData.state.common.sendSuccessMessage(message, `Your timezone is now set to **${parsedTz}**`); }, }); diff --git a/backend/src/plugins/TimeAndDate/types.ts b/backend/src/plugins/TimeAndDate/types.ts index e829228b..bcdc4b09 100644 --- a/backend/src/plugins/TimeAndDate/types.ts +++ b/backend/src/plugins/TimeAndDate/types.ts @@ -1,9 +1,10 @@ -import { BasePluginType, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "knub"; import { U } from "ts-toolbelt"; import z from "zod"; import { GuildMemberTimezones } from "../../data/GuildMemberTimezones.js"; import { keys } from "../../utils.js"; import { zValidTimezone } from "../../utils/zValidTimezone.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { defaultDateFormats } from "./defaultDateFormats.js"; const zDateFormatKeys = z.enum(keys(defaultDateFormats) as U.ListOf); @@ -18,6 +19,7 @@ export interface TimeAndDatePluginType extends BasePluginType { config: z.infer; state: { memberTimezones: GuildMemberTimezones; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts index 23433f43..3fa32e72 100644 --- a/backend/src/plugins/Utility/UtilityPlugin.ts +++ b/backend/src/plugins/Utility/UtilityPlugin.ts @@ -5,8 +5,9 @@ import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { Supporters } from "../../data/Supporters.js"; -import { makePublicFn, sendSuccessMessage } from "../../pluginUtils.js"; +import { makePublicFn } from "../../pluginUtils.js"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { ModActionsPlugin } from "../ModActions/ModActionsPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; @@ -192,11 +193,15 @@ export const UtilityPlugin = guildPlugin()({ } }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { guild } = pluginData; if (activeReloads.has(guild.id)) { - sendSuccessMessage(pluginData, activeReloads.get(guild.id)!, "Reloaded!"); + pluginData.state.common.sendSuccessMessage(activeReloads.get(guild.id)!, "Reloaded!"); activeReloads.delete(guild.id); } }, diff --git a/backend/src/plugins/Utility/commands/AvatarCmd.ts b/backend/src/plugins/Utility/commands/AvatarCmd.ts index 24628759..a4c13f85 100644 --- a/backend/src/plugins/Utility/commands/AvatarCmd.ts +++ b/backend/src/plugins/Utility/commands/AvatarCmd.ts @@ -1,6 +1,5 @@ import { APIEmbed, ImageFormat } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { UnknownUser, renderUsername } from "../../../utils.js"; import { utilityCmd } from "../types.js"; @@ -24,7 +23,7 @@ export const AvatarCmd = utilityCmd({ }; msg.channel.send({ embeds: [embed] }); } else { - sendErrorMessage(pluginData, msg.channel, "Invalid user ID"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid user ID"); } }, }); diff --git a/backend/src/plugins/Utility/commands/ChannelInfoCmd.ts b/backend/src/plugins/Utility/commands/ChannelInfoCmd.ts index 4a7617a6..f3e718fe 100644 --- a/backend/src/plugins/Utility/commands/ChannelInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/ChannelInfoCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { getChannelInfoEmbed } from "../functions/getChannelInfoEmbed.js"; import { utilityCmd } from "../types.js"; @@ -16,7 +15,7 @@ export const ChannelInfoCmd = utilityCmd({ async run({ message, args, pluginData }) { const embed = await getChannelInfoEmbed(pluginData, args.channel); if (!embed) { - sendErrorMessage(pluginData, message.channel, "Unknown channel"); + void pluginData.state.common.sendErrorMessage(message, "Unknown channel"); return; } diff --git a/backend/src/plugins/Utility/commands/CleanCmd.ts b/backend/src/plugins/Utility/commands/CleanCmd.ts index 683f28f2..db8553d3 100644 --- a/backend/src/plugins/Utility/commands/CleanCmd.ts +++ b/backend/src/plugins/Utility/commands/CleanCmd.ts @@ -1,11 +1,11 @@ -import { Message, Snowflake, TextChannel, User } from "discord.js"; +import { Message, ModalSubmitInteraction, Snowflake, TextChannel, User } from "discord.js"; import { GuildPluginData } from "knub"; import { allowTimeout } from "../../../RegExpRunner.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { LogType } from "../../../data/LogType.js"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { humanizeDurationShort } from "../../../humanizeDurationShort.js"; -import { getBaseUrl, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { getBaseUrl } from "../../../pluginUtils.js"; import { ModActionsPlugin } from "../../../plugins/ModActions/ModActionsPlugin.js"; import { DAYS, SECONDS, chunkArray, getInviteCodesInString, noop } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; @@ -77,17 +77,28 @@ export interface CleanArgs { "has-invites"?: boolean; match?: RegExp; "to-id"?: string; + "response-interaction"?: ModalSubmitInteraction; } export async function cleanCmd(pluginData: GuildPluginData, args: CleanArgs | any, msg) { if (args.count > MAX_CLEAN_COUNT || args.count <= 0) { - sendErrorMessage(pluginData, msg.channel, `Clean count must be between 1 and ${MAX_CLEAN_COUNT}`); + void pluginData.state.common.sendErrorMessage( + msg, + `Clean count must be between 1 and ${MAX_CLEAN_COUNT}`, + undefined, + args["response-interaction"], + ); return; } const targetChannel = args.channel ? pluginData.guild.channels.cache.get(args.channel as Snowflake) : msg.channel; if (!targetChannel?.isTextBased()) { - sendErrorMessage(pluginData, msg.channel, `Invalid channel specified`); + void pluginData.state.common.sendErrorMessage( + msg, + `Invalid channel specified`, + undefined, + args["response-interaction"], + ); return; } @@ -99,12 +110,20 @@ export async function cleanCmd(pluginData: GuildPluginData, a categoryId: targetChannel.parentId, }); if (configForTargetChannel.can_clean !== true) { - sendErrorMessage(pluginData, msg.channel, `Missing permissions to use clean on that channel`); + void pluginData.state.common.sendErrorMessage( + msg, + `Missing permissions to use clean on that channel`, + undefined, + args["response-interaction"], + ); return; } } - const cleaningMessage = msg.channel.send("Cleaning..."); + let cleaningMessage: Message | undefined = undefined; + if (!args["response-interaction"]) { + cleaningMessage = await msg.channel.send("Cleaning..."); + } const messagesToClean: Message[] = []; let beforeId = msg.id; @@ -202,19 +221,29 @@ export async function cleanCmd(pluginData: GuildPluginData, a } } - responseMsg = await sendSuccessMessage(pluginData, msg.channel, responseText); + responseMsg = await pluginData.state.common.sendSuccessMessage( + msg, + responseText, + undefined, + args["response-interaction"], + ); } else { const responseText = `Found no messages to clean${note ? ` (${note})` : ""}!`; - responseMsg = await sendErrorMessage(pluginData, msg.channel, responseText); + responseMsg = await pluginData.state.common.sendErrorMessage( + msg, + responseText, + undefined, + args["response-interaction"], + ); } - await (await cleaningMessage).delete(); + cleaningMessage?.delete(); if (targetChannel.id === msg.channel.id) { // Delete the !clean command and the bot response if a different channel wasn't specified // (so as not to spam the cleaned channel with the command itself) + msg.delete().catch(noop); setTimeout(() => { - msg.delete().catch(noop); responseMsg?.delete().catch(noop); }, CLEAN_COMMAND_DELETE_DELAY); } diff --git a/backend/src/plugins/Utility/commands/ContextCmd.ts b/backend/src/plugins/Utility/commands/ContextCmd.ts index efc4297d..295116ec 100644 --- a/backend/src/plugins/Utility/commands/ContextCmd.ts +++ b/backend/src/plugins/Utility/commands/ContextCmd.ts @@ -1,6 +1,5 @@ import { Snowflake, TextChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { messageLink } from "../../../utils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { utilityCmd } from "../types.js"; @@ -23,7 +22,7 @@ export const ContextCmd = utilityCmd({ async run({ message: msg, args, pluginData }) { if (args.channel && !(args.channel instanceof TextChannel)) { - sendErrorMessage(pluginData, msg.channel, "Channel must be a text channel"); + void pluginData.state.common.sendErrorMessage(msg, "Channel must be a text channel"); return; } @@ -31,7 +30,7 @@ export const ContextCmd = utilityCmd({ const messageId = args.messageId ?? args.message.messageId; if (!canReadChannel(channel, msg.member)) { - sendErrorMessage(pluginData, msg.channel, "Message context not found"); + void pluginData.state.common.sendErrorMessage(msg, "Message context not found"); return; } @@ -42,7 +41,7 @@ export const ContextCmd = utilityCmd({ }) )[0]; if (!previousMessage) { - sendErrorMessage(pluginData, msg.channel, "Message context not found"); + void pluginData.state.common.sendErrorMessage(msg, "Message context not found"); return; } diff --git a/backend/src/plugins/Utility/commands/EmojiInfoCmd.ts b/backend/src/plugins/Utility/commands/EmojiInfoCmd.ts index b4227f06..7c079e3f 100644 --- a/backend/src/plugins/Utility/commands/EmojiInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/EmojiInfoCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { getCustomEmojiId } from "../functions/getCustomEmojiId.js"; import { getEmojiInfoEmbed } from "../functions/getEmojiInfoEmbed.js"; import { utilityCmd } from "../types.js"; @@ -17,13 +16,13 @@ export const EmojiInfoCmd = utilityCmd({ async run({ message, args, pluginData }) { const emojiId = getCustomEmojiId(args.emoji); if (!emojiId) { - sendErrorMessage(pluginData, message.channel, "Emoji not found"); + void pluginData.state.common.sendErrorMessage(message, "Emoji not found"); return; } const embed = await getEmojiInfoEmbed(pluginData, emojiId); if (!embed) { - sendErrorMessage(pluginData, message.channel, "Emoji not found"); + void pluginData.state.common.sendErrorMessage(message, "Emoji not found"); return; } diff --git a/backend/src/plugins/Utility/commands/HelpCmd.ts b/backend/src/plugins/Utility/commands/HelpCmd.ts index 4c103e19..628d2a31 100644 --- a/backend/src/plugins/Utility/commands/HelpCmd.ts +++ b/backend/src/plugins/Utility/commands/HelpCmd.ts @@ -1,6 +1,5 @@ import { LoadedGuildPlugin, PluginCommandDefinition } from "knub"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { env } from "../../../env.js"; import { createChunkedMessage } from "../../../utils.js"; import { utilityCmd } from "../types.js"; diff --git a/backend/src/plugins/Utility/commands/InfoCmd.ts b/backend/src/plugins/Utility/commands/InfoCmd.ts index 519f0f6a..f286de40 100644 --- a/backend/src/plugins/Utility/commands/InfoCmd.ts +++ b/backend/src/plugins/Utility/commands/InfoCmd.ts @@ -1,7 +1,6 @@ import { Snowflake } from "discord.js"; import { getChannelId, getRoleId } from "knub/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { isValidSnowflake, noop, parseInviteCodeInput, resolveInvite, resolveUser } from "../../../utils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { resolveMessageTarget } from "../../../utils/resolveMessageTarget.js"; @@ -146,9 +145,8 @@ export const InfoCmd = utilityCmd({ } // 10. No can do - sendErrorMessage( - pluginData, - message.channel, + void pluginData.state.common.sendErrorMessage( + message, "Could not find anything with that value or you are lacking permission for the snowflake type", ); }, diff --git a/backend/src/plugins/Utility/commands/InviteInfoCmd.ts b/backend/src/plugins/Utility/commands/InviteInfoCmd.ts index 53324d0e..e553b6b5 100644 --- a/backend/src/plugins/Utility/commands/InviteInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/InviteInfoCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { parseInviteCodeInput } from "../../../utils.js"; import { getInviteInfoEmbed } from "../functions/getInviteInfoEmbed.js"; import { utilityCmd } from "../types.js"; @@ -18,7 +17,7 @@ export const InviteInfoCmd = utilityCmd({ const inviteCode = parseInviteCodeInput(args.inviteCode); const embed = await getInviteInfoEmbed(pluginData, inviteCode); if (!embed) { - sendErrorMessage(pluginData, message.channel, "Unknown invite"); + void pluginData.state.common.sendErrorMessage(message, "Unknown invite"); return; } diff --git a/backend/src/plugins/Utility/commands/JumboCmd.ts b/backend/src/plugins/Utility/commands/JumboCmd.ts index 4c29b9a2..6a693d3f 100644 --- a/backend/src/plugins/Utility/commands/JumboCmd.ts +++ b/backend/src/plugins/Utility/commands/JumboCmd.ts @@ -3,7 +3,6 @@ import { AttachmentBuilder } from "discord.js"; import fs from "fs"; import twemoji from "twemoji"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { downloadFile, isEmoji, SECONDS } from "../../../utils.js"; import { utilityCmd } from "../types.js"; @@ -51,7 +50,7 @@ export const JumboCmd = utilityCmd({ let file: AttachmentBuilder | undefined; if (!isEmoji(args.emoji)) { - sendErrorMessage(pluginData, msg.channel, "Invalid emoji"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid emoji"); return; } @@ -87,7 +86,7 @@ export const JumboCmd = utilityCmd({ } } if (!image) { - sendErrorMessage(pluginData, msg.channel, "Error occurred while jumboing default emoji"); + void pluginData.state.common.sendErrorMessage(msg, "Error occurred while jumboing default emoji"); return; } diff --git a/backend/src/plugins/Utility/commands/MessageInfoCmd.ts b/backend/src/plugins/Utility/commands/MessageInfoCmd.ts index 7ca935f4..8abff0f1 100644 --- a/backend/src/plugins/Utility/commands/MessageInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/MessageInfoCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { getMessageInfoEmbed } from "../functions/getMessageInfoEmbed.js"; import { utilityCmd } from "../types.js"; @@ -16,13 +15,13 @@ export const MessageInfoCmd = utilityCmd({ async run({ message, args, pluginData }) { if (!canReadChannel(args.message.channel, message.member)) { - sendErrorMessage(pluginData, message.channel, "Unknown message"); + void pluginData.state.common.sendErrorMessage(message, "Unknown message"); return; } const embed = await getMessageInfoEmbed(pluginData, args.message.channel.id, args.message.messageId); if (!embed) { - sendErrorMessage(pluginData, message.channel, "Unknown message"); + void pluginData.state.common.sendErrorMessage(message, "Unknown message"); return; } diff --git a/backend/src/plugins/Utility/commands/NicknameCmd.ts b/backend/src/plugins/Utility/commands/NicknameCmd.ts index 3a33b277..8c00f784 100644 --- a/backend/src/plugins/Utility/commands/NicknameCmd.ts +++ b/backend/src/plugins/Utility/commands/NicknameCmd.ts @@ -1,6 +1,6 @@ import { escapeBold } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendSuccessMessage } from "../../../pluginUtils.js"; +import { canActOn } from "../../../pluginUtils.js"; import { errorMessage } from "../../../utils.js"; import { utilityCmd } from "../types.js"; @@ -45,9 +45,8 @@ export const NicknameCmd = utilityCmd({ return; } - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Changed nickname of <@!${args.member.id}> from **${oldNickname}** to **${args.nickname}**`, ); }, diff --git a/backend/src/plugins/Utility/commands/NicknameResetCmd.ts b/backend/src/plugins/Utility/commands/NicknameResetCmd.ts index b43ef78f..df4d89c3 100644 --- a/backend/src/plugins/Utility/commands/NicknameResetCmd.ts +++ b/backend/src/plugins/Utility/commands/NicknameResetCmd.ts @@ -1,5 +1,5 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendSuccessMessage } from "../../../pluginUtils.js"; +import { canActOn } from "../../../pluginUtils.js"; import { errorMessage } from "../../../utils.js"; import { utilityCmd } from "../types.js"; @@ -31,6 +31,6 @@ export const NicknameResetCmd = utilityCmd({ return; } - sendSuccessMessage(pluginData, msg.channel, `The nickname of <@!${args.member.id}> has been reset`); + void pluginData.state.common.sendSuccessMessage(msg, `The nickname of <@!${args.member.id}> has been reset`); }, }); diff --git a/backend/src/plugins/Utility/commands/RolesCmd.ts b/backend/src/plugins/Utility/commands/RolesCmd.ts index 82a2e2c1..e9fb5f75 100644 --- a/backend/src/plugins/Utility/commands/RolesCmd.ts +++ b/backend/src/plugins/Utility/commands/RolesCmd.ts @@ -1,6 +1,5 @@ import { Role } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { chunkArray, sorter, trimLines } from "../../../utils.js"; import { refreshMembersIfNeeded } from "../refreshMembers.js"; import { utilityCmd } from "../types.js"; @@ -62,7 +61,7 @@ export const RolesCmd = utilityCmd({ } else if (sort === "name") { roles.sort(sorter((r) => r.name.toLowerCase(), sortDir)); } else { - sendErrorMessage(pluginData, msg.channel, "Unknown sorting method"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown sorting method"); return; } diff --git a/backend/src/plugins/Utility/commands/ServerInfoCmd.ts b/backend/src/plugins/Utility/commands/ServerInfoCmd.ts index bb19a20f..01e6e762 100644 --- a/backend/src/plugins/Utility/commands/ServerInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/ServerInfoCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { getServerInfoEmbed } from "../functions/getServerInfoEmbed.js"; import { utilityCmd } from "../types.js"; @@ -17,7 +16,7 @@ export const ServerInfoCmd = utilityCmd({ const serverId = args.serverId || pluginData.guild.id; const serverInfoEmbed = await getServerInfoEmbed(pluginData, serverId); if (!serverInfoEmbed) { - sendErrorMessage(pluginData, message.channel, "Could not find information for that server"); + void pluginData.state.common.sendErrorMessage(message, "Could not find information for that server"); return; } diff --git a/backend/src/plugins/Utility/commands/SourceCmd.ts b/backend/src/plugins/Utility/commands/SourceCmd.ts index b02f18dd..dde7d537 100644 --- a/backend/src/plugins/Utility/commands/SourceCmd.ts +++ b/backend/src/plugins/Utility/commands/SourceCmd.ts @@ -1,6 +1,6 @@ import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { getBaseUrl, sendErrorMessage } from "../../../pluginUtils.js"; +import { getBaseUrl } from "../../../pluginUtils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { utilityCmd } from "../types.js"; @@ -16,13 +16,13 @@ export const SourceCmd = utilityCmd({ async run({ message: cmdMessage, args, pluginData }) { if (!canReadChannel(args.message.channel, cmdMessage.member)) { - sendErrorMessage(pluginData, cmdMessage.channel, "Unknown message"); + void pluginData.state.common.sendErrorMessage(cmdMessage, "Unknown message"); return; } const message = await args.message.channel.messages.fetch(args.message.messageId); if (!message) { - sendErrorMessage(pluginData, cmdMessage.channel, "Unknown message"); + void pluginData.state.common.sendErrorMessage(cmdMessage, "Unknown message"); return; } diff --git a/backend/src/plugins/Utility/commands/UserInfoCmd.ts b/backend/src/plugins/Utility/commands/UserInfoCmd.ts index f180a9b5..99603f84 100644 --- a/backend/src/plugins/Utility/commands/UserInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/UserInfoCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { getUserInfoEmbed } from "../functions/getUserInfoEmbed.js"; import { utilityCmd } from "../types.js"; @@ -19,7 +18,7 @@ export const UserInfoCmd = utilityCmd({ const userId = args.user?.id || message.author.id; const embed = await getUserInfoEmbed(pluginData, userId, args.compact); if (!embed) { - sendErrorMessage(pluginData, message.channel, "User not found"); + void pluginData.state.common.sendErrorMessage(message, "User not found"); return; } diff --git a/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts b/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts index c8a5d320..8c849d98 100644 --- a/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts +++ b/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts @@ -1,6 +1,6 @@ import { VoiceChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { canActOn } from "../../../pluginUtils.js"; import { renderUsername } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { utilityCmd } from "../types.js"; @@ -17,12 +17,12 @@ export const VcdisconnectCmd = utilityCmd({ async run({ message: msg, args, pluginData }) { if (!canActOn(pluginData, msg.member, args.member)) { - sendErrorMessage(pluginData, msg.channel, "Cannot move: insufficient permissions"); + void pluginData.state.common.sendErrorMessage(msg, "Cannot move: insufficient permissions"); return; } if (!args.member.voice?.channelId) { - sendErrorMessage(pluginData, msg.channel, "Member is not in a voice channel"); + void pluginData.state.common.sendErrorMessage(msg, "Member is not in a voice channel"); return; } const channel = pluginData.guild.channels.cache.get(args.member.voice.channelId) as VoiceChannel; @@ -30,7 +30,7 @@ export const VcdisconnectCmd = utilityCmd({ try { await args.member.voice.disconnect(); } catch { - sendErrorMessage(pluginData, msg.channel, "Failed to disconnect member"); + void pluginData.state.common.sendErrorMessage(msg, "Failed to disconnect member"); return; } @@ -40,9 +40,8 @@ export const VcdisconnectCmd = utilityCmd({ oldChannel: channel, }); - sendSuccessMessage( - pluginData, - msg.channel, + pluginData.state.common.sendSuccessMessage( + msg, `**${renderUsername(args.member)}** disconnected from **${channel.name}**`, ); }, diff --git a/backend/src/plugins/Utility/commands/VcmoveCmd.ts b/backend/src/plugins/Utility/commands/VcmoveCmd.ts index 0ccc5724..d8bee3ac 100644 --- a/backend/src/plugins/Utility/commands/VcmoveCmd.ts +++ b/backend/src/plugins/Utility/commands/VcmoveCmd.ts @@ -1,6 +1,6 @@ import { ChannelType, Snowflake, VoiceChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { canActOn } from "../../../pluginUtils.js"; import { channelMentionRegex, isSnowflake, renderUsername, simpleClosestStringMatch } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { utilityCmd } from "../types.js"; @@ -23,7 +23,7 @@ export const VcmoveCmd = utilityCmd({ // Snowflake -> resolve channel directly const potentialChannel = pluginData.guild.channels.cache.get(args.channel as Snowflake); if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { - sendErrorMessage(pluginData, msg.channel, "Unknown or non-voice channel"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown or non-voice channel"); return; } @@ -33,7 +33,7 @@ export const VcmoveCmd = utilityCmd({ const channelId = args.channel.match(channelMentionRegex)![1]; const potentialChannel = pluginData.guild.channels.cache.get(channelId as Snowflake); if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { - sendErrorMessage(pluginData, msg.channel, "Unknown or non-voice channel"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown or non-voice channel"); return; } @@ -45,7 +45,7 @@ export const VcmoveCmd = utilityCmd({ ); const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, (ch) => ch.name); if (!closestMatch) { - sendErrorMessage(pluginData, msg.channel, "No matching voice channels"); + void pluginData.state.common.sendErrorMessage(msg, "No matching voice channels"); return; } @@ -53,12 +53,12 @@ export const VcmoveCmd = utilityCmd({ } if (!args.member.voice?.channelId) { - sendErrorMessage(pluginData, msg.channel, "Member is not in a voice channel"); + void pluginData.state.common.sendErrorMessage(msg, "Member is not in a voice channel"); return; } if (args.member.voice.channelId === channel.id) { - sendErrorMessage(pluginData, msg.channel, "Member is already on that channel!"); + void pluginData.state.common.sendErrorMessage(msg, "Member is already on that channel!"); return; } @@ -69,7 +69,7 @@ export const VcmoveCmd = utilityCmd({ channel: channel.id, }); } catch { - sendErrorMessage(pluginData, msg.channel, "Failed to move member"); + void pluginData.state.common.sendErrorMessage(msg, "Failed to move member"); return; } @@ -80,7 +80,10 @@ export const VcmoveCmd = utilityCmd({ newChannel: channel, }); - sendSuccessMessage(pluginData, msg.channel, `**${renderUsername(args.member)}** moved to **${channel.name}**`); + void pluginData.state.common.sendSuccessMessage( + msg, + `**${renderUsername(args.member)}** moved to **${channel.name}**`, + ); }, }); @@ -102,7 +105,7 @@ export const VcmoveAllCmd = utilityCmd({ // Snowflake -> resolve channel directly const potentialChannel = pluginData.guild.channels.cache.get(args.channel as Snowflake); if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { - sendErrorMessage(pluginData, msg.channel, "Unknown or non-voice channel"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown or non-voice channel"); return; } @@ -112,7 +115,7 @@ export const VcmoveAllCmd = utilityCmd({ const channelId = args.channel.match(channelMentionRegex)![1]; const potentialChannel = pluginData.guild.channels.cache.get(channelId as Snowflake); if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { - sendErrorMessage(pluginData, msg.channel, "Unknown or non-voice channel"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown or non-voice channel"); return; } @@ -124,7 +127,7 @@ export const VcmoveAllCmd = utilityCmd({ ); const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, (ch) => ch.name); if (!closestMatch) { - sendErrorMessage(pluginData, msg.channel, "No matching voice channels"); + void pluginData.state.common.sendErrorMessage(msg, "No matching voice channels"); return; } @@ -132,12 +135,12 @@ export const VcmoveAllCmd = utilityCmd({ } if (args.oldChannel.members.size === 0) { - sendErrorMessage(pluginData, msg.channel, "Voice channel is empty"); + void pluginData.state.common.sendErrorMessage(msg, "Voice channel is empty"); return; } if (args.oldChannel.id === channel.id) { - sendErrorMessage(pluginData, msg.channel, "Cant move from and to the same channel!"); + void pluginData.state.common.sendErrorMessage(msg, "Cant move from and to the same channel!"); return; } @@ -150,9 +153,8 @@ export const VcmoveAllCmd = utilityCmd({ // Check for permissions but allow self-moves if (currMember.id !== msg.member.id && !canActOn(pluginData, msg.member, currMember)) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `Failed to move ${renderUsername(currMember)} (${currMember.id}): You cannot act on this member`, ); errAmt++; @@ -165,10 +167,13 @@ export const VcmoveAllCmd = utilityCmd({ }); } catch { if (msg.member.id === currMember.id) { - sendErrorMessage(pluginData, msg.channel, "Unknown error when trying to move members"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown error when trying to move members"); return; } - sendErrorMessage(pluginData, msg.channel, `Failed to move ${renderUsername(currMember)} (${currMember.id})`); + void pluginData.state.common.sendErrorMessage( + msg, + `Failed to move ${renderUsername(currMember)} (${currMember.id})`, + ); errAmt++; continue; } @@ -182,13 +187,12 @@ export const VcmoveAllCmd = utilityCmd({ } if (moveAmt !== errAmt) { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `${moveAmt - errAmt} members from **${args.oldChannel.name}** moved to **${channel.name}**`, ); } else { - sendErrorMessage(pluginData, msg.channel, `Failed to move any members.`); + void pluginData.state.common.sendErrorMessage(msg, `Failed to move any members.`); } }, }); diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts index f362ee1b..a2a23700 100644 --- a/backend/src/plugins/Utility/search.ts +++ b/backend/src/plugins/Utility/search.ts @@ -13,7 +13,7 @@ import escapeStringRegexp from "escape-string-regexp"; import { ArgsFromSignatureOrArray, GuildPluginData } from "knub"; import moment from "moment-timezone"; import { RegExpRunner, allowTimeout } from "../../RegExpRunner.js"; -import { getBaseUrl, sendErrorMessage } from "../../pluginUtils.js"; +import { getBaseUrl } from "../../pluginUtils.js"; import { InvalidRegexError, MINUTES, @@ -122,12 +122,12 @@ export async function displaySearch( } } catch (e) { if (e instanceof SearchError) { - sendErrorMessage(pluginData, msg.channel, e.message); + void pluginData.state.common.sendErrorMessage(msg, e.message); return; } if (e instanceof InvalidRegexError) { - sendErrorMessage(pluginData, msg.channel, e.message); + void pluginData.state.common.sendErrorMessage(msg, e.message); return; } @@ -135,7 +135,7 @@ export async function displaySearch( } if (searchResult.totalResults === 0) { - sendErrorMessage(pluginData, msg.channel, "No results found"); + void pluginData.state.common.sendErrorMessage(msg, "No results found"); return; } @@ -266,12 +266,12 @@ export async function archiveSearch( } } catch (e) { if (e instanceof SearchError) { - sendErrorMessage(pluginData, msg.channel, e.message); + void pluginData.state.common.sendErrorMessage(msg, e.message); return; } if (e instanceof InvalidRegexError) { - sendErrorMessage(pluginData, msg.channel, e.message); + void pluginData.state.common.sendErrorMessage(msg, e.message); return; } @@ -279,7 +279,7 @@ export async function archiveSearch( } if (results.totalResults === 0) { - sendErrorMessage(pluginData, msg.channel, "No results found"); + void pluginData.state.common.sendErrorMessage(msg, "No results found"); return; } diff --git a/backend/src/plugins/Utility/types.ts b/backend/src/plugins/Utility/types.ts index 466eb6f9..2ab37f3e 100644 --- a/backend/src/plugins/Utility/types.ts +++ b/backend/src/plugins/Utility/types.ts @@ -1,4 +1,4 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; import z from "zod"; import { RegExpRunner } from "../../RegExpRunner.js"; import { GuildArchives } from "../../data/GuildArchives.js"; @@ -6,6 +6,7 @@ import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { Supporters } from "../../data/Supporters.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zUtilityConfig = z.strictObject({ can_roles: z.boolean(), @@ -48,6 +49,8 @@ export interface UtilityPluginType extends BasePluginType { regexRunner: RegExpRunner; lastReload: number; + + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index ddb82e21..46653ce8 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -11,6 +11,8 @@ import { CasesPlugin } from "./Cases/CasesPlugin.js"; import { casesPluginDocs } from "./Cases/docs.js"; import { CensorPlugin } from "./Censor/CensorPlugin.js"; import { censorPluginDocs } from "./Censor/docs.js"; +import { CommonPlugin } from "./Common/CommonPlugin.js"; +import { commonPluginDocs } from "./Common/docs.js"; import { CompanionChannelsPlugin } from "./CompanionChannels/CompanionChannelsPlugin.js"; import { companionChannelsPluginDocs } from "./CompanionChannels/docs.js"; import { ContextMenuPlugin } from "./ContextMenus/ContextMenuPlugin.js"; @@ -229,6 +231,11 @@ export const availableGuildPlugins: ZeppelinGuildPluginInfo[] = [ plugin: WelcomeMessagePlugin, docs: welcomeMessagePluginDocs, }, + { + plugin: CommonPlugin, + docs: commonPluginDocs, + autoload: true, + }, ]; export const availableGlobalPlugins: ZeppelinGlobalPluginInfo[] = [ diff --git a/backend/src/templateFormatter.test.ts b/backend/src/templateFormatter.test.ts index 0d7bd993..f906e054 100644 --- a/backend/src/templateFormatter.test.ts +++ b/backend/src/templateFormatter.test.ts @@ -1,10 +1,5 @@ import test from "ava"; -import { - parseTemplate, - renderParsedTemplate, - renderTemplate, - TemplateSafeValueContainer, -} from "./templateFormatter.js"; +import { parseTemplate, renderParsedTemplate, renderTemplate, TemplateSafeValueContainer } from "./templateFormatter.js"; test("Parses plain string templates correctly", (t) => { const result = parseTemplate("foo bar baz"); diff --git a/backend/src/types.ts b/backend/src/types.ts index 8b195f66..dfc9037a 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -1,29 +1,12 @@ -import { BaseConfig, GlobalPluginBlueprint, GuildPluginBlueprint, Knub } from "knub"; +import { GlobalPluginBlueprint, GuildPluginBlueprint, Knub } from "knub"; import z, { ZodTypeAny } from "zod"; import { zSnowflake } from "./utils.js"; -export interface ZeppelinGuildConfig extends BaseConfig { - success_emoji?: string; - error_emoji?: string; - - // Deprecated - timezone?: string; - date_formats?: any; -} - export const zZeppelinGuildConfig = z.strictObject({ // From BaseConfig prefix: z.string().optional(), levels: z.record(zSnowflake, z.number()).optional(), plugins: z.record(z.string(), z.unknown()).optional(), - - // From ZeppelinGuildConfig - success_emoji: z.string().optional(), - error_emoji: z.string().optional(), - - // Deprecated - timezone: z.string().optional(), - date_formats: z.unknown().optional(), }); export type TZeppelinKnub = Knub; diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 214d3f20..9a35ea73 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -1,6 +1,7 @@ import { APIEmbed, ChannelType, + ChatInputCommandInteraction, Client, DiscordAPIError, EmbedData, @@ -1305,11 +1306,11 @@ export async function resolveStickerId(bot: Client, id: Snowflake): Promise { - return waitForButtonConfirm(channel, content, { restrictToId: userId }); + return waitForButtonConfirm(context, content, { restrictToId: userId }); } export function messageSummary(msg: SavedMessage) { diff --git a/backend/src/utils/createPaginatedMessage.ts b/backend/src/utils/createPaginatedMessage.ts index 2f6c6fb3..aebf165a 100644 --- a/backend/src/utils/createPaginatedMessage.ts +++ b/backend/src/utils/createPaginatedMessage.ts @@ -1,4 +1,5 @@ import { + ChatInputCommandInteraction, Client, Message, MessageCreateOptions, @@ -6,9 +7,9 @@ import { MessageReaction, PartialMessageReaction, PartialUser, - TextBasedChannel, User, } from "discord.js"; +import { sendContextResponse } from "../pluginUtils.js"; import { MINUTES, noop } from "../utils.js"; import { Awaitable } from "./typeUtils.js"; import Timeout = NodeJS.Timeout; @@ -27,14 +28,14 @@ const defaultOpts: PaginateMessageOpts = { export async function createPaginatedMessage( client: Client, - channel: TextBasedChannel | User, + context: Message | 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/loadYamlSafely.ts b/backend/src/utils/loadYamlSafely.ts index 9cfb68ae..07fdb66e 100644 --- a/backend/src/utils/loadYamlSafely.ts +++ b/backend/src/utils/loadYamlSafely.ts @@ -5,7 +5,7 @@ import { validateNoObjectAliases } from "./validateNoObjectAliases.js"; * Loads a YAML file safely while removing object anchors/aliases (including arrays) */ export function loadYamlSafely(yamlStr: string): any { - let loaded = yaml.safeLoad(yamlStr); + let loaded = yaml.load(yamlStr); if (loaded == null || typeof loaded !== "object") { loaded = {}; } 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 ec44fb7c..6cf9ad07 100644 --- a/backend/src/utils/waitForInteraction.ts +++ b/backend/src/utils/waitForInteraction.ts @@ -2,22 +2,26 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, - GuildTextBasedChannel, + ChatInputCommandInteraction, + Message, MessageActionRowComponentBuilder, MessageComponentInteraction, MessageCreateOptions, + User, } from "discord.js"; import moment from "moment"; import { v4 as uuidv4 } from "uuid"; +import { isContextInteraction } from "../pluginUtils.js"; import { noop } from "../utils.js"; export async function waitForButtonConfirm( - channel: GuildTextBasedChannel, - toPost: MessageCreateOptions, + context: Message | User | ChatInputCommandInteraction, + toPost: Omit, 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,15 @@ export async function waitForButtonConfirm( .setLabel(options?.cancelText || "Cancel") .setCustomId(`cancelButton:${idMod}:${uuidv4()}`), ]); - const message = await channel.send({ ...toPost, components: [row] }); + const sendMethod = () => { + if (contextIsInteraction) { + return context.replied ? context.editReply.bind(context) : context.reply.bind(context); + } else { + return "send" in context ? context.send.bind(context) : context.channel.send.bind(context.channel); + } + }; + const extraParameters = contextIsInteraction ? { fetchReply: true, ephemeral: true } : {}; + const message = (await sendMethod()({ ...toPost, components: [row], ...extraParameters })) as Message; const collector = message.createMessageComponentCollector({ time: 10000 }); @@ -41,16 +53,16 @@ export async function waitForButtonConfirm( .catch((err) => console.trace(err.message)); } else { if (interaction.customId.startsWith(`confirmButton:${idMod}:`)) { - message.delete(); + if (!contextIsInteraction) message.delete(); resolve(true); } else if (interaction.customId.startsWith(`cancelButton:${idMod}:`)) { - message.delete(); + if (!contextIsInteraction) message.delete(); resolve(false); } } }); collector.on("end", () => { - if (message.deletable) message.delete().catch(noop); + if (!contextIsInteraction && message.deletable) message.delete().catch(noop); resolve(false); }); }); diff --git a/backend/src/validateActiveConfigs.ts b/backend/src/validateActiveConfigs.ts index 327f97db..aff64fd4 100644 --- a/backend/src/validateActiveConfigs.ts +++ b/backend/src/validateActiveConfigs.ts @@ -1,12 +1,10 @@ -import jsYaml from "js-yaml"; +import { YAMLException } from "js-yaml"; import { validateGuildConfig } from "./configValidator.js"; import { Configs } from "./data/Configs.js"; import { connect, disconnect } from "./data/db.js"; import { loadYamlSafely } from "./utils/loadYamlSafely.js"; import { ObjectAliasError } from "./utils/validateNoObjectAliases.js"; -const YAMLException = jsYaml.YAMLException; - function writeError(key: string, error: string) { const indented = error .split("\n")