diff --git a/package.json b/package.json index 9940979b859..db373bb2fa7 100644 --- a/package.json +++ b/package.json @@ -7,24 +7,28 @@ "main": "./dist/Skyra.js", "type": "module", "imports": { - "#lib/database": "./dist/lib/database/index.js", + "#utils/common": "./dist/lib/util/common/index.js", + "#utils/functions": "./dist/lib/util/functions/index.js", + "#utils/resolvers": "./dist/lib/util/resolvers/index.js", + "#utils/*": "./dist/lib/util/*.js", "#lib/database/entities": "./dist/lib/database/entities/index.js", "#lib/database/keys": "./dist/lib/database/keys/index.js", "#lib/database/settings": "./dist/lib/database/settings/index.js", + "#lib/database": "./dist/lib/database/index.js", "#lib/discord": "./dist/lib/discord/index.js", - "#lib/moderation": "./dist/lib/moderation/index.js", + "#lib/moderation/actions": "./dist/lib/moderation/actions/index.js", + "#lib/moderation/common": "./dist/lib/moderation/common/index.js", "#lib/moderation/managers": "./dist/lib/moderation/managers/index.js", "#lib/moderation/workers": "./dist/lib/moderation/workers/index.js", - "#lib/structures": "./dist/lib/structures/index.js", + "#lib/moderation": "./dist/lib/moderation/index.js", + "#lib/structures/data": "./dist/lib/structures/data/index.js", "#lib/structures/managers": "./dist/lib/structures/managers/index.js", + "#lib/structures": "./dist/lib/structures/index.js", "#lib/setup": "./dist/lib/setup/index.js", "#lib/types": "./dist/lib/types/index.js", "#lib/i18n/languageKeys": "./dist/lib/i18n/languageKeys/index.js", "#lib/*": "./dist/lib/*.js", "#languages": "./dist/languages/index.js", - "#utils/common": "./dist/lib/util/common/index.js", - "#utils/functions": "./dist/lib/util/functions/index.js", - "#utils/*": "./dist/lib/util/*.js", "#root/*": "./dist/*.js" }, "scripts": { diff --git a/src/arguments/case.ts b/src/arguments/case.ts index 2b33e3c8294..d5a12bbf101 100644 --- a/src/arguments/case.ts +++ b/src/arguments/case.ts @@ -1,16 +1,9 @@ -import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { getModeration } from '#utils/functions'; -import { Argument, Resolvers } from '@sapphire/framework'; - -const minimum = 0; -const maximum = 2_147_483_647; // Maximum value for int32 +import { resolveCaseId } from '#utils/resolvers'; +import { Argument } from '@sapphire/framework'; export class UserArgument extends Argument { public async run(parameter: string, context: Argument.Context) { - const latest = context.args.t(LanguageKeys.Arguments.CaseLatestOptions); - if (latest.includes(parameter)) return this.ok(await getModeration(context.message.guild!).getCurrentId()); - - return Resolvers.resolveInteger(parameter, { minimum, maximum }) // - .mapErrInto((identifier) => this.error({ parameter, identifier, context })); + return (await resolveCaseId(parameter, context.args.t, context.message.guild!)) // + .mapErrInto((error) => this.error({ parameter, identifier: error.identifier, context: { ...(error.context as object), ...context } })); } } diff --git a/src/arguments/timespan.ts b/src/arguments/timespan.ts index 1e74b2bcda0..0dc8d78e45c 100644 --- a/src/arguments/timespan.ts +++ b/src/arguments/timespan.ts @@ -1,37 +1,9 @@ -import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { seconds } from '#utils/common'; +import { resolveTimeSpan } from '#utils/resolvers'; import { Argument } from '@sapphire/framework'; -import { Duration } from '@sapphire/time-utilities'; export class UserArgument extends Argument { public run(parameter: string, context: Argument.Context) { - const duration = this.parseParameter(parameter); - - if (!Number.isSafeInteger(duration)) { - return this.error({ parameter, identifier: LanguageKeys.Arguments.TimeSpan, context }); - } - - if (typeof context.minimum === 'number' && duration < context.minimum) { - return this.error({ parameter, identifier: LanguageKeys.Arguments.TimeSpanTooSmall, context }); - } - - if (typeof context.maximum === 'number' && duration > context.maximum) { - return this.error({ parameter, identifier: LanguageKeys.Arguments.TimeSpanTooBig, context }); - } - - return this.ok(duration); - } - - private parseParameter(parameter: string): number { - const number = Number(parameter); - if (!Number.isNaN(number)) return seconds(number); - - const duration = new Duration(parameter).offset; - if (!Number.isNaN(duration)) return duration; - - const date = Date.parse(parameter); - if (!Number.isNaN(date)) return date - Date.now(); - - return NaN; + return resolveTimeSpan(parameter, { minimum: context.minimum, maximum: context.maximum }) // + .mapErrInto((identifier) => this.error({ parameter, identifier, context })); } } diff --git a/src/commands/Management/create-mute.ts b/src/commands/Management/create-mute.ts index c14d6fd9a2a..57e39ae5206 100644 --- a/src/commands/Management/create-mute.ts +++ b/src/commands/Management/create-mute.ts @@ -1,10 +1,11 @@ import { GuildSettings, writeSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; +import { ModerationActions } from '#lib/moderation'; import { SkyraCommand } from '#lib/structures'; import { PermissionLevels, type GuildMessage } from '#lib/types'; import { minutes } from '#utils/common'; import { Emojis } from '#utils/constants'; -import { getEmojiReactionFormat, getSecurity, promptConfirmation, promptForMessage, type SerializedEmoji } from '#utils/functions'; +import { getEmojiReactionFormat, promptConfirmation, promptForMessage, type SerializedEmoji } from '#utils/functions'; import { ApplyOptions } from '@sapphire/decorators'; import { canReact } from '@sapphire/discord.js-utilities'; import { Argument, CommandOptionsRunTypeEnum, Result, UserError } from '@sapphire/framework'; @@ -41,7 +42,7 @@ export class UserCommand extends SkyraCommand { return send(message, content); } } else if (await promptConfirmation(message, t(LanguageKeys.Commands.Moderation.ActionSharedRoleSetupNew))) { - await getSecurity(message.guild).actions.muteSetup(message); + await ModerationActions.mute.setup(message); const content = t(LanguageKeys.Commands.Moderation.Success); await send(message, content); diff --git a/src/commands/Moderation/Management/history.ts b/src/commands/Moderation/Management/history.ts deleted file mode 100644 index 9c2449aa565..00000000000 --- a/src/commands/Moderation/Management/history.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { ModerationEntity } from '#lib/database'; -import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { SkyraPaginatedMessage, SkyraSubcommand } from '#lib/structures'; -import { PermissionLevels, type GuildMessage } from '#lib/types'; -import { seconds } from '#utils/common'; -import { getModeration } from '#utils/functions'; -import { TypeVariation } from '#utils/moderationConstants'; -import { getColor, getFullEmbedAuthor, sendLoadingMessage } from '#utils/util'; -import { TimestampStyles, time } from '@discordjs/builders'; -import { ApplyOptions, RequiresClientPermissions } from '@sapphire/decorators'; -import { CommandOptionsRunTypeEnum } from '@sapphire/framework'; -import { send } from '@sapphire/plugin-editable-commands'; -import { chunk, cutText } from '@sapphire/utilities'; -import { EmbedBuilder, PermissionFlagsBits, type Collection } from 'discord.js'; - -const COLORS = [0x80f31f, 0xa5de0b, 0xc7c101, 0xe39e03, 0xf6780f, 0xfe5326, 0xfb3244]; - -@ApplyOptions({ - aliases: ['hd', 'ho'], - description: LanguageKeys.Commands.Moderation.HistoryDescription, - detailedDescription: LanguageKeys.Commands.Moderation.HistoryExtended, - permissionLevel: PermissionLevels.Moderator, - runIn: [CommandOptionsRunTypeEnum.GuildAny], - subcommands: [ - { name: 'details', messageRun: 'details' }, - { name: 'overview', messageRun: 'overview', default: true } - ] -}) -export class UserCommand extends SkyraSubcommand { - public override messageRun(message: GuildMessage, args: SkyraSubcommand.Args, context: SkyraSubcommand.RunContext) { - if (context.commandName === 'hd') return this.details(message, args); - if (context.commandName === 'ho') return this.overview(message, args); - return super.messageRun(message, args, context); - } - - public async overview(message: GuildMessage, args: SkyraSubcommand.Args) { - const target = args.finished ? message.author : await args.pick('userName'); - const logs = await getModeration(message.guild).fetch(target.id); - let warnings = 0; - let mutes = 0; - let kicks = 0; - let bans = 0; - - for (const log of logs.values()) { - if (log.invalidated || log.appealType) continue; - switch (log.type) { - case TypeVariation.Ban: - case TypeVariation.SoftBan: - ++bans; - break; - case TypeVariation.Mute: - ++mutes; - break; - case TypeVariation.Kick: - ++kicks; - break; - case TypeVariation.Warning: - ++warnings; - break; - default: - break; - } - } - - const index = Math.min(COLORS.length - 1, warnings + mutes + kicks + bans); - const footer = args.t(LanguageKeys.Commands.Moderation.HistoryFooterNew, { - warnings, - mutes, - kicks, - bans, - warningsText: args.t(LanguageKeys.Commands.Moderation.HistoryFooterWarning, { count: warnings }), - mutesText: args.t(LanguageKeys.Commands.Moderation.HistoryFooterMutes, { count: mutes }), - kicksText: args.t(LanguageKeys.Commands.Moderation.HistoryFooterKicks, { count: kicks }), - bansText: args.t(LanguageKeys.Commands.Moderation.HistoryFooterBans, { count: bans }) - }); - - const embed = new EmbedBuilder() // - .setColor(COLORS[index]) - .setAuthor(getFullEmbedAuthor(target)) - .setFooter({ text: footer }); - await send(message, { embeds: [embed] }); - } - - @RequiresClientPermissions(PermissionFlagsBits.EmbedLinks) - public async details(message: GuildMessage, args: SkyraSubcommand.Args) { - const target = args.finished ? message.author : await args.pick('userName'); - const response = await sendLoadingMessage(message, args.t); - - const entries = (await getModeration(message.guild).fetch(target.id)).filter((log) => !log.invalidated && !log.appealType); - if (!entries.size) this.error(LanguageKeys.Commands.Moderation.ModerationsEmpty); - - const display = new SkyraPaginatedMessage({ - template: new EmbedBuilder() - .setColor(getColor(message)) - .setTitle(args.t(LanguageKeys.Commands.Moderation.ModerationsAmount, { count: entries.size })) - }); - - // Fetch usernames - const usernames = await this.fetchAllModerators(entries); - - // Lock in the current time - const now = Date.now(); - - for (const page of chunk([...entries.values()], 10)) { - display.addPageEmbed((embed) => { - for (const entry of page) { - embed.addFields(this.displayModerationLogFromModerators(usernames, now, entry)); - } - - return embed; - }); - } - - await display.run(response, message.author); - } - - private displayModerationLogFromModerators(users: Map, now: number, entry: ModerationEntity) { - const appealOrInvalidated = entry.appealType || entry.invalidated; - const remainingTime = - appealOrInvalidated || entry.duration === null || entry.createdAt === null ? null : entry.createdTimestamp + entry.duration! - Date.now(); - const expiredTime = remainingTime !== null && remainingTime <= 0; - const formattedModerator = users.get(entry.moderatorId!); - const formattedReason = entry.reason ? cutText(entry.reason, 800) : 'None'; - const formattedDuration = - remainingTime === null || expiredTime - ? '' - : `\nExpires: ${time(seconds.fromMilliseconds(now + remainingTime), TimestampStyles.RelativeTime)}`; - const formatter = expiredTime || appealOrInvalidated ? '~~' : ''; - - return { - name: `\`${entry.caseId}\` | ${entry.title}`, - value: `${formatter}Moderator: **${formattedModerator}**.\n${formattedReason}${formattedDuration}${formatter}` - }; - } - - private async fetchAllModerators(entries: Collection) { - const moderators = new Map() as Map; - for (const entry of entries.values()) { - const id = entry.moderatorId!; - if (!moderators.has(id)) moderators.set(id, (await entry.fetchModerator()).username); - } - return moderators; - } -} diff --git a/src/commands/Moderation/Management/moderations.ts b/src/commands/Moderation/Management/moderations.ts deleted file mode 100644 index ebe7125591e..00000000000 --- a/src/commands/Moderation/Management/moderations.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { ModerationEntity } from '#lib/database'; -import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { SkyraCommand, SkyraPaginatedMessage, SkyraSubcommand } from '#lib/structures'; -import { PermissionLevels, type GuildMessage } from '#lib/types'; -import { seconds } from '#utils/common'; -import { getModeration } from '#utils/functions'; -import { TypeVariation } from '#utils/moderationConstants'; -import { getColor, sendLoadingMessage } from '#utils/util'; -import { TimestampStyles, time } from '@discordjs/builders'; -import { ApplyOptions } from '@sapphire/decorators'; -import { CommandOptionsRunTypeEnum } from '@sapphire/framework'; -import { chunk, cutText } from '@sapphire/utilities'; -import { EmbedBuilder, type Collection, type User } from 'discord.js'; - -const enum Type { - Mute, - Warning, - All -} - -@ApplyOptions( - SkyraSubcommand.PaginatedOptions({ - aliases: ['moderation'], - description: LanguageKeys.Commands.Moderation.ModerationsDescription, - detailedDescription: LanguageKeys.Commands.Moderation.ModerationsExtended, - permissionLevel: PermissionLevels.Moderator, - runIn: [CommandOptionsRunTypeEnum.GuildAny], - subcommands: [ - { name: 'mute', messageRun: 'mutes' }, - { name: 'mutes', messageRun: 'mutes' }, - { name: 'warning', messageRun: 'warnings' }, - { name: 'warnings', messageRun: 'warnings' }, - { name: 'warn', messageRun: 'warnings' }, - { name: 'warns', messageRun: 'warnings' }, - { name: 'all', messageRun: 'all', default: true } - ] - }) -) -export class UserPaginatedMessageCommand extends SkyraSubcommand { - public mutes(message: GuildMessage, args: SkyraSubcommand.Args, { commandPrefix }: SkyraSubcommand.RunContext) { - return this.handle(message, args, Type.Mute, commandPrefix); - } - - public warnings(message: GuildMessage, args: SkyraCommand.Args, { commandPrefix }: SkyraCommand.RunContext) { - return this.handle(message, args, Type.Warning, commandPrefix); - } - - public all(message: GuildMessage, args: SkyraCommand.Args, { commandPrefix }: SkyraCommand.RunContext) { - return this.handle(message, args, Type.All, commandPrefix); - } - - private async handle(message: GuildMessage, args: SkyraCommand.Args, action: Type, prefix: string) { - const target = args.finished ? null : await args.pick('userName'); - - const response = await sendLoadingMessage(message, args.t); - const moderation = getModeration(message.guild); - const entries = (await (target ? moderation.fetch(target.id) : moderation.fetch())).filter(this.getFilter(action, target)); - - if (!entries.size) this.error(LanguageKeys.Commands.Moderation.ModerationsEmpty, { prefix }); - - const display = new SkyraPaginatedMessage({ - template: new EmbedBuilder() - .setColor(getColor(message)) - .setTitle(args.t(LanguageKeys.Commands.Moderation.ModerationsAmount, { count: entries.size })) - }); - - // Fetch usernames - const usernames = await (target ? this.fetchAllModerators(entries) : this.fetchAllUsers(entries)); - - // Lock in the current time - const now = Date.now(); - - const displayName = action === Type.All; - const format = target - ? this.displayModerationLogFromModerators.bind(this, usernames, now, displayName) - : this.displayModerationLogFromUsers.bind(this, usernames, now, displayName); - - for (const page of chunk([...entries.values()], 10)) { - display.addPageEmbed((embed) => { - for (const entry of page) { - embed.addFields(format(entry)); - } - - return embed; - }); - } - - await display.run(response, message.author); - return response; - } - - private displayModerationLogFromModerators(users: Map, now: number, displayName: boolean, entry: ModerationEntity) { - const appealOrInvalidated = entry.appealType || entry.invalidated; - const remainingTime = - appealOrInvalidated || entry.duration === null || entry.createdAt === null ? null : entry.createdTimestamp + entry.duration! - Date.now(); - const expiredTime = remainingTime !== null && remainingTime <= 0; - const formattedModerator = users.get(entry.moderatorId!); - const formattedReason = entry.reason ? cutText(entry.reason, 800) : 'None'; - const formattedDuration = - remainingTime === null || expiredTime - ? '' - : `\nExpires: ${time(seconds.fromMilliseconds(now + remainingTime), TimestampStyles.RelativeTime)}`; - const formatter = appealOrInvalidated || expiredTime ? '~~' : ''; - - return { - name: `\`${entry.caseId}\`${displayName ? ` | ${entry.title}` : ''}`, - value: `${formatter}Moderator: **${formattedModerator}**.\n${formattedReason}${formattedDuration}${formatter}` - }; - } - - private displayModerationLogFromUsers(users: Map, now: number, displayName: boolean, entry: ModerationEntity) { - const appealOrInvalidated = entry.appealType || entry.invalidated; - const remainingTime = - appealOrInvalidated || entry.duration === null || entry.createdAt === null ? null : entry.createdTimestamp + entry.duration! - Date.now(); - const expiredTime = remainingTime !== null && remainingTime <= 0; - const formattedUser = users.get(entry.userId!); - const formattedReason = entry.reason ? cutText(entry.reason, 800) : 'None'; - const formattedDuration = - remainingTime === null || expiredTime - ? '' - : `\nExpires: ${time(seconds.fromMilliseconds(now + remainingTime), TimestampStyles.RelativeTime)}`; - const formatter = appealOrInvalidated || expiredTime ? '~~' : ''; - - return { - name: `\`${entry.caseId}\`${displayName ? ` | ${entry.title}` : ''}`, - value: `${formatter}Moderator: **${formattedUser}**.\n${formattedReason}${formattedDuration}${formatter}` - }; - } - - private async fetchAllUsers(entries: Collection) { - const users = new Map() as Map; - for (const entry of entries.values()) { - const id = entry.userId!; - if (!users.has(id)) users.set(id, (await entry.fetchUser()).username); - } - return users; - } - - private async fetchAllModerators(entries: Collection) { - const moderators = new Map() as Map; - for (const entry of entries.values()) { - const id = entry.moderatorId!; - if (!moderators.has(id)) moderators.set(id, (await entry.fetchModerator()).username); - } - return moderators; - } - - private getFilter(type: Type, target: User | null) { - switch (type) { - case Type.Mute: - return target - ? (entry: ModerationEntity) => - entry.type === TypeVariation.Mute && !entry.invalidated && !entry.appealType && entry.userId === target.id - : (entry: ModerationEntity) => entry.type === TypeVariation.Mute && !entry.invalidated && !entry.appealType; - case Type.Warning: - return target - ? (entry: ModerationEntity) => - entry.type === TypeVariation.Warning && !entry.invalidated && !entry.appealType && entry.userId === target.id - : (entry: ModerationEntity) => entry.type === TypeVariation.Warning && !entry.invalidated && !entry.appealType; - case Type.All: - return target - ? (entry: ModerationEntity) => entry.duration !== null && !entry.invalidated && !entry.appealType && entry.userId === target.id - : (entry: ModerationEntity) => entry.duration !== null && !entry.invalidated && !entry.appealType; - } - } -} diff --git a/src/commands/Moderation/Management/mutes.ts b/src/commands/Moderation/Management/mutes.ts deleted file mode 100644 index e021fb73349..00000000000 --- a/src/commands/Moderation/Management/mutes.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { SkyraCommand } from '#lib/structures'; -import { PermissionLevels, type GuildMessage } from '#lib/types'; -import type { UserPaginatedMessageCommand as Moderations } from '#root/commands/Moderation/Management/moderations'; -import { ApplyOptions } from '@sapphire/decorators'; -import { CommandOptionsRunTypeEnum } from '@sapphire/framework'; - -@ApplyOptions( - SkyraCommand.PaginatedOptions({ - description: LanguageKeys.Commands.Moderation.MutesDescription, - detailedDescription: LanguageKeys.Commands.Moderation.MutesExtended, - permissionLevel: PermissionLevels.Moderator, - runIn: [CommandOptionsRunTypeEnum.GuildAny] - }) -) -export class UserPaginatedMessageCommand extends SkyraCommand { - public override messageRun(message: GuildMessage, args: SkyraCommand.Args, context: SkyraCommand.RunContext) { - const command = this.store.get('moderations') as Moderations | undefined; - if (typeof command === 'undefined') throw new Error('Moderations command not loaded yet.'); - return command.mutes(message, args, context); - } -} diff --git a/src/commands/Moderation/Management/warnings.ts b/src/commands/Moderation/Management/warnings.ts deleted file mode 100644 index 500a2e37de5..00000000000 --- a/src/commands/Moderation/Management/warnings.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { SkyraCommand } from '#lib/structures'; -import { PermissionLevels, type GuildMessage } from '#lib/types'; -import type { UserPaginatedMessageCommand as Moderations } from '#root/commands/Moderation/Management/moderations'; -import { ApplyOptions } from '@sapphire/decorators'; -import { CommandOptionsRunTypeEnum } from '@sapphire/framework'; - -@ApplyOptions( - SkyraCommand.PaginatedOptions({ - description: LanguageKeys.Commands.Moderation.WarningsDescription, - detailedDescription: LanguageKeys.Commands.Moderation.WarningsExtended, - permissionLevel: PermissionLevels.Moderator, - runIn: [CommandOptionsRunTypeEnum.GuildAny] - }) -) -export class UserPaginatedMessageCommand extends SkyraCommand { - public override messageRun(message: GuildMessage, args: SkyraCommand.Args, context: SkyraCommand.RunContext) { - const moderations = this.store.get('moderations') as Moderations | undefined; - if (typeof moderations === 'undefined') throw new Error('Moderations command not loaded yet.'); - return moderations.warnings(message, args, context); - } -} diff --git a/src/commands/Moderation/Restriction/restrictAttachment.ts b/src/commands/Moderation/Restriction/restrictAttachment.ts index 22b5e84dd74..533ff57e24f 100644 --- a/src/commands/Moderation/Restriction/restrictAttachment.ts +++ b/src/commands/Moderation/Restriction/restrictAttachment.ts @@ -1,34 +1,15 @@ -import { GuildSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SetUpModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { ModerationSetupRestriction } from '#utils/Security/ModerationActions'; -import { getImage } from '#utils/util'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; -import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.RestrictedAttachment; +type ValueType = null; + +@ApplyOptions>({ aliases: ['restricted-attachment', 'ra'], description: LanguageKeys.Commands.Moderation.RestrictAttachmentDescription, detailedDescription: LanguageKeys.Commands.Moderation.RestrictAttachmentExtended, - optionalDuration: true, - requiredMember: true, - requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - roleKey: GuildSettings.Roles.RestrictedAttachment, - setUpKey: ModerationSetupRestriction.Attachment + type: TypeVariation.RestrictedAttachment }) -export class UserSetUpModerationCommand extends SetUpModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.restrictAttachment( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message), - duration: context.duration - }, - await this.getTargetDM(message, context.args, context.target) - ); - } -} +export class UserSetUpModerationCommand extends SetUpModerationCommand {} diff --git a/src/commands/Moderation/Restriction/restrictEmbed.ts b/src/commands/Moderation/Restriction/restrictEmbed.ts index 37fe5c83285..23880717607 100644 --- a/src/commands/Moderation/Restriction/restrictEmbed.ts +++ b/src/commands/Moderation/Restriction/restrictEmbed.ts @@ -1,34 +1,15 @@ -import { GuildSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SetUpModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { ModerationSetupRestriction } from '#utils/Security/ModerationActions'; -import { getImage } from '#utils/util'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; -import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.RestrictedEmbed; +type ValueType = null; + +@ApplyOptions>({ aliases: ['restricted-embed', 're'], description: LanguageKeys.Commands.Moderation.RestrictEmbedDescription, detailedDescription: LanguageKeys.Commands.Moderation.RestrictEmbedExtended, - optionalDuration: true, - requiredMember: true, - requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - roleKey: GuildSettings.Roles.RestrictedEmbed, - setUpKey: ModerationSetupRestriction.Embed + type: TypeVariation.RestrictedEmbed }) -export class UserSetUpModerationCommand extends SetUpModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.restrictEmbed( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message), - duration: context.duration - }, - await this.getTargetDM(message, context.args, context.target) - ); - } -} +export class UserSetUpModerationCommand extends SetUpModerationCommand {} diff --git a/src/commands/Moderation/Restriction/restrictEmoji.ts b/src/commands/Moderation/Restriction/restrictEmoji.ts index ed00153741d..a3ac1748b48 100644 --- a/src/commands/Moderation/Restriction/restrictEmoji.ts +++ b/src/commands/Moderation/Restriction/restrictEmoji.ts @@ -1,34 +1,15 @@ -import { GuildSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SetUpModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { ModerationSetupRestriction } from '#utils/Security/ModerationActions'; -import { getImage } from '#utils/util'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; -import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.RestrictedEmoji; +type ValueType = null; + +@ApplyOptions>({ aliases: ['restrict-external-emoji', 'restricted-emoji', 'restricted-external-emoji', 'ree', 'restrict-emojis'], description: LanguageKeys.Commands.Moderation.RestrictEmojiDescription, detailedDescription: LanguageKeys.Commands.Moderation.RestrictEmojiExtended, - optionalDuration: true, - requiredMember: true, - requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - roleKey: GuildSettings.Roles.RestrictedEmoji, - setUpKey: ModerationSetupRestriction.Emoji + type: TypeVariation.RestrictedEmoji }) -export class UserSetUpModerationCommand extends SetUpModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.restrictEmoji( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message), - duration: context.duration - }, - await this.getTargetDM(message, context.args, context.target) - ); - } -} +export class UserSetUpModerationCommand extends SetUpModerationCommand {} diff --git a/src/commands/Moderation/Restriction/restrictReaction.ts b/src/commands/Moderation/Restriction/restrictReaction.ts index facdb7597d8..50cc8746a01 100644 --- a/src/commands/Moderation/Restriction/restrictReaction.ts +++ b/src/commands/Moderation/Restriction/restrictReaction.ts @@ -1,34 +1,15 @@ -import { GuildSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SetUpModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { ModerationSetupRestriction } from '#utils/Security/ModerationActions'; -import { getImage } from '#utils/util'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; -import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.RestrictedReaction; +type ValueType = null; + +@ApplyOptions>({ aliases: ['restricted-reaction', 'rr'], description: LanguageKeys.Commands.Moderation.RestrictReactionDescription, detailedDescription: LanguageKeys.Commands.Moderation.RestrictReactionExtended, - optionalDuration: true, - requiredMember: true, - requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - roleKey: GuildSettings.Roles.RestrictedReaction, - setUpKey: ModerationSetupRestriction.Reaction + type: TypeVariation.RestrictedReaction }) -export class UserSetUpModerationCommand extends SetUpModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.restrictReaction( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message), - duration: context.duration - }, - await this.getTargetDM(message, context.args, context.target) - ); - } -} +export class UserSetUpModerationCommand extends SetUpModerationCommand {} diff --git a/src/commands/Moderation/Restriction/restrictVoice.ts b/src/commands/Moderation/Restriction/restrictVoice.ts index cad0a7c306a..921f2d0dd6a 100644 --- a/src/commands/Moderation/Restriction/restrictVoice.ts +++ b/src/commands/Moderation/Restriction/restrictVoice.ts @@ -1,34 +1,15 @@ -import { GuildSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SetUpModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { ModerationSetupRestriction } from '#utils/Security/ModerationActions'; -import { getImage } from '#utils/util'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; -import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.RestrictedVoice; +type ValueType = null; + +@ApplyOptions>({ aliases: ['restricted-voice', 'rv'], description: LanguageKeys.Commands.Moderation.RestrictVoiceDescription, detailedDescription: LanguageKeys.Commands.Moderation.RestrictVoiceExtended, - optionalDuration: true, - requiredMember: true, - requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - roleKey: GuildSettings.Roles.RestrictedVoice, - setUpKey: ModerationSetupRestriction.Voice + type: TypeVariation.RestrictedVoice }) -export class UserSetUpModerationCommand extends SetUpModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.restrictVoice( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message), - duration: context.duration - }, - await this.getTargetDM(message, context.args, context.target) - ); - } -} +export class UserSetUpModerationCommand extends SetUpModerationCommand {} diff --git a/src/commands/Moderation/Restriction/unrestrictAttachment.ts b/src/commands/Moderation/Restriction/unrestrictAttachment.ts index 705e910b985..c6b70407299 100644 --- a/src/commands/Moderation/Restriction/unrestrictAttachment.ts +++ b/src/commands/Moderation/Restriction/unrestrictAttachment.ts @@ -1,29 +1,16 @@ -import { GuildSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SetUpModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { ModerationSetupRestriction } from '#utils/Security/ModerationActions'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; -import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.RestrictedAttachment; +type ValueType = null; + +@ApplyOptions>({ aliases: ['un-restricted-attachment', 'ura'], description: LanguageKeys.Commands.Moderation.UnrestrictAttachmentDescription, detailedDescription: LanguageKeys.Commands.Moderation.UnrestrictAttachmentExtended, - requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - roleKey: GuildSettings.Roles.RestrictedAttachment, - setUpKey: ModerationSetupRestriction.Attachment + type: TypeVariation.RestrictedAttachment, + isUndoAction: true }) -export class UserSetUpModerationCommand extends SetUpModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.unRestrictAttachment( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason - }, - await this.getTargetDM(message, context.args, context.target) - ); - } -} +export class UserSetUpModerationCommand extends SetUpModerationCommand {} diff --git a/src/commands/Moderation/Restriction/unrestrictEmbed.ts b/src/commands/Moderation/Restriction/unrestrictEmbed.ts index 1214e980d35..24b80dd5cf0 100644 --- a/src/commands/Moderation/Restriction/unrestrictEmbed.ts +++ b/src/commands/Moderation/Restriction/unrestrictEmbed.ts @@ -1,29 +1,16 @@ -import { GuildSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SetUpModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { ModerationSetupRestriction } from '#utils/Security/ModerationActions'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; -import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.RestrictedEmbed; +type ValueType = null; + +@ApplyOptions>({ aliases: ['un-restricted-embed', 'ure'], description: LanguageKeys.Commands.Moderation.UnrestrictEmbedDescription, detailedDescription: LanguageKeys.Commands.Moderation.UnrestrictEmbedExtended, - requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - roleKey: GuildSettings.Roles.RestrictedEmbed, - setUpKey: ModerationSetupRestriction.Embed + type: TypeVariation.RestrictedEmbed, + isUndoAction: true }) -export class UserSetUpModerationCommand extends SetUpModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.unRestrictEmbed( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason - }, - await this.getTargetDM(message, context.args, context.target) - ); - } -} +export class UserSetUpModerationCommand extends SetUpModerationCommand {} diff --git a/src/commands/Moderation/Restriction/unrestrictEmoji.ts b/src/commands/Moderation/Restriction/unrestrictEmoji.ts index 5229c0da159..cc18c34ba0c 100644 --- a/src/commands/Moderation/Restriction/unrestrictEmoji.ts +++ b/src/commands/Moderation/Restriction/unrestrictEmoji.ts @@ -1,29 +1,16 @@ -import { GuildSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SetUpModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { ModerationSetupRestriction } from '#utils/Security/ModerationActions'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; -import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.RestrictedEmoji; +type ValueType = null; + +@ApplyOptions>({ aliases: ['un-restrict-external-emoji', 'unrestricted-emoji', 'unrestricted-external-emoji', 'uree', 'unrestrict-emojis'], description: LanguageKeys.Commands.Moderation.UnrestrictEmojiDescription, detailedDescription: LanguageKeys.Commands.Moderation.UnrestrictEmojiExtended, - requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - roleKey: GuildSettings.Roles.RestrictedEmoji, - setUpKey: ModerationSetupRestriction.Emoji + type: TypeVariation.RestrictedEmoji, + isUndoAction: true }) -export class UserSetUpModerationCommand extends SetUpModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.unRestrictEmoji( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason - }, - await this.getTargetDM(message, context.args, context.target) - ); - } -} +export class UserSetUpModerationCommand extends SetUpModerationCommand {} diff --git a/src/commands/Moderation/Restriction/unrestrictReaction.ts b/src/commands/Moderation/Restriction/unrestrictReaction.ts index fa548f01698..e27fa817b7d 100644 --- a/src/commands/Moderation/Restriction/unrestrictReaction.ts +++ b/src/commands/Moderation/Restriction/unrestrictReaction.ts @@ -1,29 +1,16 @@ -import { GuildSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SetUpModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { ModerationSetupRestriction } from '#utils/Security/ModerationActions'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; -import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.RestrictedReaction; +type ValueType = null; + +@ApplyOptions>({ aliases: ['un-restricted-reaction', 'urr'], description: LanguageKeys.Commands.Moderation.UnrestrictReactionDescription, detailedDescription: LanguageKeys.Commands.Moderation.UnrestrictReactionExtended, - requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - roleKey: GuildSettings.Roles.RestrictedReaction, - setUpKey: ModerationSetupRestriction.Reaction + type: TypeVariation.RestrictedReaction, + isUndoAction: true }) -export class UserSetUpModerationCommand extends SetUpModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.unRestrictReaction( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason - }, - await this.getTargetDM(message, context.args, context.target) - ); - } -} +export class UserSetUpModerationCommand extends SetUpModerationCommand {} diff --git a/src/commands/Moderation/Restriction/unrestrictVoice.ts b/src/commands/Moderation/Restriction/unrestrictVoice.ts index 603190c0e7f..4b4b30c6715 100644 --- a/src/commands/Moderation/Restriction/unrestrictVoice.ts +++ b/src/commands/Moderation/Restriction/unrestrictVoice.ts @@ -1,29 +1,16 @@ -import { GuildSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SetUpModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { ModerationSetupRestriction } from '#utils/Security/ModerationActions'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; -import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.RestrictedVoice; +type ValueType = null; + +@ApplyOptions>({ aliases: ['un-restricted-voice', 'urv'], description: LanguageKeys.Commands.Moderation.UnrestrictVoiceDescription, detailedDescription: LanguageKeys.Commands.Moderation.UnrestrictVoiceExtended, - requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - roleKey: GuildSettings.Roles.RestrictedVoice, - setUpKey: ModerationSetupRestriction.Voice + type: TypeVariation.RestrictedVoice, + isUndoAction: true }) -export class UserSetUpModerationCommand extends SetUpModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.unRestrictVoice( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason - }, - await this.getTargetDM(message, context.args, context.target) - ); - } -} +export class UserSetUpModerationCommand extends SetUpModerationCommand {} diff --git a/src/commands/Moderation/Utilities/case-deprecations.ts b/src/commands/Moderation/Utilities/case-deprecations.ts new file mode 100644 index 00000000000..b963b2ab29e --- /dev/null +++ b/src/commands/Moderation/Utilities/case-deprecations.ts @@ -0,0 +1,47 @@ +import { LanguageKeys } from '#lib/i18n/languageKeys'; +import { SkyraCommand } from '#lib/structures'; +import { PermissionLevels, type GuildMessage } from '#lib/types'; +import { ApplyOptions } from '@sapphire/decorators'; +import { CommandOptionsRunTypeEnum } from '@sapphire/framework'; +import { send } from '@sapphire/plugin-editable-commands'; +import { chatInputApplicationCommandMention } from 'discord.js'; + +@ApplyOptions({ + name: '\u200Bcase-deprecations', + aliases: ['reason', 'time', 'unwarn', 'uw', 'unwarning', 'history', 'hd', 'ho', 'moderation', 'moderations', 'mutes', 'warnings'], + description: LanguageKeys.Commands.General.V7Description, + detailedDescription: LanguageKeys.Commands.General.V7Extended, + permissionLevel: PermissionLevels.Moderator, + runIn: [CommandOptionsRunTypeEnum.GuildAny] +}) +export class UserCommand extends SkyraCommand { + public override async messageRun(message: GuildMessage, args: SkyraCommand.Args, context: SkyraCommand.RunContext) { + const caseCommand = this.store.get('case')!; + const id = this.getGlobalCommandId.call(caseCommand); + const command = chatInputApplicationCommandMention(caseCommand.name, this.#getSubcommand(context.commandName), id); + const content = args.t(LanguageKeys.Commands.Shared.DeprecatedMessage, { command }); + return send(message, content); + } + + #getSubcommand(name: string) { + switch (name) { + case 'reason': + case 'time': + return 'edit'; + case 'unwarn': + case 'unwarning': + case 'uw': + return 'archive'; + case 'hd': + case 'history': + case 'ho': + case 'moderation': + case 'moderations': + case 'mutes': + case 'warnings': + return 'list'; + default: + return 'view'; + } + } +} diff --git a/src/commands/Moderation/Utilities/case.ts b/src/commands/Moderation/Utilities/case.ts index e8d56bdf7f8..63038d03b86 100644 --- a/src/commands/Moderation/Utilities/case.ts +++ b/src/commands/Moderation/Utilities/case.ts @@ -1,47 +1,324 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; +import { getSupportedLanguageT, getSupportedUserLanguageT } from '#lib/i18n/translate'; +import { ModerationManager, getAction } from '#lib/moderation'; +import { getEmbed, getTitle, getTranslationKey } from '#lib/moderation/common'; import { SkyraCommand, SkyraSubcommand } from '#lib/structures'; import { PermissionLevels, type GuildMessage } from '#lib/types'; +import { desc, minutes, seconds, years } from '#utils/common'; +import { BrandingColors, Emojis } from '#utils/constants'; import { getModeration } from '#utils/functions'; +import { TypeVariation } from '#utils/moderationConstants'; +import { resolveCase, resolveTimeSpan } from '#utils/resolvers'; +import { getFullEmbedAuthor, isUserSelf } from '#utils/util'; import { ApplyOptions } from '@sapphire/decorators'; -import { CommandOptionsRunTypeEnum } from '@sapphire/framework'; +import { PaginatedMessageEmbedFields } from '@sapphire/discord.js-utilities'; +import { ApplicationCommandRegistry, CommandOptionsRunTypeEnum } from '@sapphire/framework'; import { send } from '@sapphire/plugin-editable-commands'; -import { PermissionFlagsBits } from 'discord.js'; +import { applyLocalizedBuilder, createLocalizedChoice, type TFunction } from '@sapphire/plugin-i18next'; +import { cutText, isNullish, isNullishOrEmpty, isNullishOrZero } from '@sapphire/utilities'; +import { + EmbedBuilder, + PermissionFlagsBits, + TimestampStyles, + User, + blockQuote, + chatInputApplicationCommandMention, + inlineCode, + time, + userMention, + type EmbedField +} from 'discord.js'; + +const Root = LanguageKeys.Commands.Case; +const RootModeration = LanguageKeys.Moderation; +const OverviewColors = [0x80f31f, 0xa5de0b, 0xc7c101, 0xe39e03, 0xf6780f, 0xfe5326, 0xfb3244]; @ApplyOptions({ - description: LanguageKeys.Commands.Moderation.CaseDescription, - detailedDescription: LanguageKeys.Commands.Moderation.CaseExtended, + description: Root.Description, + detailedDescription: LanguageKeys.Commands.Shared.SlashDetailed, permissionLevel: PermissionLevels.Moderator, requiredClientPermissions: [PermissionFlagsBits.EmbedLinks], runIn: [CommandOptionsRunTypeEnum.GuildAny], + hidden: true, subcommands: [ - { name: 'delete', messageRun: 'delete' }, - { name: 'show', messageRun: 'show', default: true } + { name: 'view', chatInputRun: 'chatInputRunView', messageRun: 'viewMessageRun', default: true }, + { name: 'list', chatInputRun: 'chatInputRunList' }, + { name: 'edit', chatInputRun: 'chatInputRunEdit' }, + { name: 'archive', chatInputRun: 'chatInputRunArchive' }, + { name: 'delete', chatInputRun: 'chatInputRunDelete', messageRun: 'deleteMessageRun' } ] }) export class UserCommand extends SkyraSubcommand { - public async show(message: GuildMessage, args: SkyraSubcommand.Args) { - const caseId = await args.pick('case'); - - const moderation = getModeration(message.guild); - const entry = await moderation.fetch(caseId); - if (entry) { - const embed = await entry.prepareEmbed(); - return send(message, { embeds: [embed] }); + public override registerApplicationCommands(registry: ApplicationCommandRegistry) { + registry.registerChatInputCommand((builder) => + applyLocalizedBuilder(builder, Root.Name, Root.Description) + .setDMPermission(false) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((subcommand) => + applyLocalizedBuilder(subcommand, Root.View) // + .addIntegerOption((option) => applyLocalizedBuilder(option, Root.OptionsCase).setMinValue(1).setRequired(true)) + .addBooleanOption((option) => applyLocalizedBuilder(option, Root.OptionsShow)) + ) + .addSubcommand((subcommand) => + applyLocalizedBuilder(subcommand, Root.List) // + .addUserOption((option) => applyLocalizedBuilder(option, Root.OptionsUser)) + .addBooleanOption((option) => applyLocalizedBuilder(option, Root.OptionsOverview)) + .addBooleanOption((option) => applyLocalizedBuilder(option, Root.OptionsShow)) + .addIntegerOption((option) => + applyLocalizedBuilder(option, Root.OptionsType) // + .addChoices( + createLocalizedChoice(RootModeration.TypeRoleAdd, { value: TypeVariation.RoleAdd }), + createLocalizedChoice(RootModeration.TypeBan, { value: TypeVariation.Ban }), + createLocalizedChoice(RootModeration.TypeKick, { value: TypeVariation.Kick }), + createLocalizedChoice(RootModeration.TypeMute, { value: TypeVariation.Mute }), + createLocalizedChoice(RootModeration.TypeRoleRemove, { value: TypeVariation.RoleRemove }), + createLocalizedChoice(RootModeration.TypeRestrictedAttachment, { value: TypeVariation.RestrictedAttachment }), + createLocalizedChoice(RootModeration.TypeRestrictedEmbed, { value: TypeVariation.RestrictedEmbed }), + createLocalizedChoice(RootModeration.TypeRestrictedEmoji, { value: TypeVariation.RestrictedEmoji }), + createLocalizedChoice(RootModeration.TypeRestrictedReaction, { value: TypeVariation.RestrictedReaction }), + createLocalizedChoice(RootModeration.TypeRestrictedVoice, { value: TypeVariation.RestrictedVoice }), + createLocalizedChoice(RootModeration.TypeSetNickname, { value: TypeVariation.SetNickname }), + createLocalizedChoice(RootModeration.TypeSoftban, { value: TypeVariation.Softban }), + createLocalizedChoice(RootModeration.TypeVoiceKick, { value: TypeVariation.VoiceKick }), + createLocalizedChoice(RootModeration.TypeVoiceMute, { value: TypeVariation.VoiceMute }), + createLocalizedChoice(RootModeration.TypeWarning, { value: TypeVariation.Warning }) + ) + ) + .addBooleanOption((option) => applyLocalizedBuilder(option, Root.OptionsPendingOnly)) + ) + .addSubcommand((subcommand) => + applyLocalizedBuilder(subcommand, Root.Edit) // + .addIntegerOption((option) => applyLocalizedBuilder(option, Root.OptionsCase).setMinValue(1).setRequired(true)) + .addStringOption((option) => applyLocalizedBuilder(option, Root.OptionsReason).setMaxLength(200)) + .addStringOption((option) => applyLocalizedBuilder(option, Root.OptionsDuration).setMaxLength(50)) + ) + .addSubcommand((subcommand) => + applyLocalizedBuilder(subcommand, Root.Archive) // + .addIntegerOption((option) => applyLocalizedBuilder(option, Root.OptionsCase).setMinValue(1).setRequired(true)) + ) + .addSubcommand((subcommand) => + applyLocalizedBuilder(subcommand, Root.Delete) // + .addIntegerOption((option) => applyLocalizedBuilder(option, Root.OptionsCase).setMinValue(1).setRequired(true)) + ) + ); + } + + public async chatInputRunView(interaction: SkyraSubcommand.Interaction) { + const entry = await this.#getCase(interaction, true); + const show = interaction.options.getBoolean('show') ?? false; + const t = show ? getSupportedLanguageT(interaction) : getSupportedUserLanguageT(interaction); + + return interaction.reply({ embeds: [await getEmbed(t, entry)], ephemeral: !show }); + } + + public async chatInputRunList(interaction: SkyraSubcommand.Interaction) { + const user = interaction.options.getUser('user'); + const show = interaction.options.getBoolean('show') ?? false; + const type = interaction.options.getInteger('type') as TypeVariation | null; + const pendingOnly = interaction.options.getBoolean('pending-only') ?? false; + + const moderation = getModeration(interaction.guild); + let entries = [...(await moderation.fetch({ userId: user?.id })).values()]; + if (!isNullish(type)) entries = entries.filter((entry) => entry.type === type); + if (pendingOnly) entries = entries.filter((entry) => !isNullishOrZero(entry.duration) && !entry.isCompleted()); + + const t = show ? getSupportedLanguageT(interaction) : getSupportedUserLanguageT(interaction); + return interaction.options.getBoolean('overview') // + ? this.#listOverview(interaction, t, entries, user, show) + : this.#listDetails(interaction, t, this.#sortEntries(entries), isNullish(user), show); + } + + public async chatInputRunEdit(interaction: SkyraSubcommand.Interaction) { + const entry = await this.#getCase(interaction, true); + const reason = interaction.options.getString('reason'); + const duration = this.#getDuration(interaction); + + const moderation = getModeration(interaction.guild); + const t = getSupportedUserLanguageT(interaction); + if (!isNullish(duration)) { + const action = getAction(entry.type); + if (!action.isUndoActionAvailable) { + const content = t(Root.TimeNotAllowed, { type: t(getTranslationKey(entry.type)) }); + return interaction.reply({ content, ephemeral: true }); + } + + if (entry.isCompleted()) { + const content = t(Root.TimeNotAllowedInCompletedEntries, { caseId: entry.id }); + return interaction.reply({ content, ephemeral: true }); + } + + if (duration !== 0) { + const next = entry.createdAt + duration; + if (next <= Date.now()) { + const content = t(Root.TimeTooEarly, { + start: time(seconds.fromMilliseconds(entry.createdAt), TimestampStyles.LongDateTime), + time: time(seconds.fromMilliseconds(next), TimestampStyles.RelativeTime) + }); + return interaction.reply({ content, ephemeral: true }); + } + } } - this.error(LanguageKeys.Commands.Moderation.ReasonNotExists); + + await moderation.edit(entry, { + reason: isNullish(reason) ? entry.reason : reason, + duration: isNullish(duration) ? entry.duration : duration || null + }); + + const content = t(Root.EditSuccess, { caseId: entry.id }); + return interaction.reply({ content, ephemeral: true }); + } + + public async chatInputRunArchive(interaction: SkyraSubcommand.Interaction) { + const entry = await this.#getCase(interaction, true); + await getModeration(interaction.guild).archive(entry); + + const content = getSupportedUserLanguageT(interaction)(Root.ArchiveSuccess, { caseId: entry.id }); + return interaction.reply({ content, ephemeral: true }); } - public async delete(message: GuildMessage, args: SkyraCommand.Args) { - const caseId = await args.pick('case'); + public async chatInputRunDelete(interaction: SkyraSubcommand.Interaction) { + const entry = await this.#getCase(interaction, true); + await getModeration(interaction.guild).delete(entry); - const moderation = getModeration(message.guild); - const entry = await moderation.fetch(caseId); - if (!entry) this.error(LanguageKeys.Commands.Moderation.ReasonNotExists); + const content = getSupportedUserLanguageT(interaction)(Root.DeleteSuccess, { caseId: entry.id }); + return interaction.reply({ content, ephemeral: true }); + } + + public async viewMessageRun(message: GuildMessage, args: SkyraSubcommand.Args) { + return send(message, { + content: args.t(LanguageKeys.Commands.Shared.DeprecatedMessage, { + command: chatInputApplicationCommandMention(this.name, 'show', this.getGlobalCommandId()) + }) + }); + } + + public async deleteMessageRun(message: GuildMessage, args: SkyraCommand.Args) { + return send(message, { + content: args.t(LanguageKeys.Commands.Shared.DeprecatedMessage, { + command: chatInputApplicationCommandMention(this.name, 'delete', this.getGlobalCommandId()) + }) + }); + } + + async #listDetails( + interaction: SkyraSubcommand.Interaction, + t: TFunction, + entries: ModerationManager.Entry[], + displayUser: boolean, + show: boolean + ) { + if (entries.length === 0) { + const content = getSupportedUserLanguageT(interaction)(Root.ListEmpty); + return interaction.reply({ content, ephemeral: true }); + } + + await interaction.deferReply({ ephemeral: !show }); + + const title = t(Root.ListDetailsTitle, { count: entries.length }); + const color = interaction.member?.displayColor ?? BrandingColors.Primary; + return new PaginatedMessageEmbedFields() + .setTemplate(new EmbedBuilder().setTitle(title).setColor(color)) + .setIdle(minutes(5)) + .setItemsPerPage(5) + .setItems(entries.map((entry) => this.#listDetailsEntry(t, entry, displayUser))) + .make() + .run(interaction, interaction.user); + } + + #listDetailsEntry(t: TFunction, entry: ModerationManager.Entry, displayUser: boolean): EmbedField { + const moderatorEmoji = isUserSelf(entry.moderatorId) ? Emojis.AutoModerator : Emojis.Moderator; + const lines = [ + `${Emojis.Calendar} ${time(seconds.fromMilliseconds(entry.createdAt), TimestampStyles.ShortDateTime)}`, + t(Root.ListDetailsModerator, { emoji: moderatorEmoji, mention: userMention(entry.moderatorId), userId: entry.moderatorId }) + ]; + if (displayUser && entry.userId) { + lines.push(t(Root.ListDetailsUser, { emoji: Emojis.ShieldMember, mention: userMention(entry.userId), userId: entry.userId })); + } + + if (!isNullishOrZero(entry.duration) && !entry.expired) { + const timestamp = time(seconds.fromMilliseconds(entry.expiresTimestamp!), TimestampStyles.RelativeTime); + lines.push(t(Root.ListDetailsExpires, { emoji: Emojis.Hourglass, time: timestamp })); + } + + if (!isNullishOrEmpty(entry.reason)) lines.push(blockQuote(cutText(entry.reason, 150))); + + return { + name: `${inlineCode(entry.id.toString())} → ${getTitle(t, entry)}`, + value: lines.join('\n'), + inline: false + }; + } + + async #listOverview( + interaction: SkyraSubcommand.Interaction, + t: TFunction, + entries: ModerationManager.Entry[], + user: User | null, + show: boolean + ) { + let [warnings, mutes, timeouts, kicks, bans] = [0, 0, 0, 0, 0]; + for (const entry of entries) { + if (entry.isArchived() || entry.isUndo()) continue; + switch (entry.type) { + case TypeVariation.Ban: + case TypeVariation.Softban: + ++bans; + break; + case TypeVariation.Mute: + ++mutes; + break; + case TypeVariation.Timeout: + ++timeouts; + break; + case TypeVariation.Kick: + ++kicks; + break; + case TypeVariation.Warning: + ++warnings; + break; + default: + break; + } + } + + const footer = t(user ? Root.ListOverviewFooterUser : Root.ListOverviewFooter, { + warnings: t(Root.ListOverviewFooterWarning, { count: warnings }), + mutes: t(Root.ListOverviewFooterMutes, { count: mutes }), + timeouts: t(Root.ListOverviewFooterTimeouts, { count: timeouts }), + kicks: t(Root.ListOverviewFooterKicks, { count: kicks }), + bans: t(Root.ListOverviewFooterBans, { count: bans }) + }); + + const embed = new EmbedBuilder() + .setColor(OverviewColors[Math.min(OverviewColors.length - 1, warnings + mutes + kicks + bans)]) + .setFooter({ text: footer }); + if (user) embed.setAuthor(getFullEmbedAuthor(user)); + await interaction.reply({ embeds: [embed], ephemeral: !show }); + } + + #sortEntries(entries: ModerationManager.Entry[]) { + return entries.sort((a, b) => desc(a.id, b.id)); + } + + #getDuration(interaction: SkyraSubcommand.Interaction, required: true): number; + #getDuration(interaction: SkyraSubcommand.Interaction, required?: false): number | null; + #getDuration(interaction: SkyraSubcommand.Interaction, required?: boolean) { + const parameter = interaction.options.getString('duration', required); + if (isNullish(parameter)) return null; + + return resolveTimeSpan(parameter, { minimum: 0, maximum: years(1) }) // + .mapErr((key) => getSupportedUserLanguageT(interaction)(key, { parameter: parameter.toString() })) + .unwrap(); + } - await entry.remove(); - moderation.delete(entry.caseId); + async #getCase(interaction: SkyraSubcommand.Interaction, required: true): Promise; + async #getCase(interaction: SkyraSubcommand.Interaction, required?: false): Promise; + async #getCase(interaction: SkyraSubcommand.Interaction, required?: boolean) { + const caseId = interaction.options.getInteger('case', required); + if (isNullish(caseId)) return null; - const content = args.t(LanguageKeys.Commands.Moderation.CaseDeleted, { case: entry.caseId }); - return send(message, content); + const parameter = caseId.toString(); + const t = getSupportedUserLanguageT(interaction); + return (await resolveCase(parameter, t, interaction.guild)).unwrap(); } } diff --git a/src/commands/Moderation/Utilities/reason.ts b/src/commands/Moderation/Utilities/reason.ts deleted file mode 100644 index 57e75a6bca7..00000000000 --- a/src/commands/Moderation/Utilities/reason.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { SkyraCommand } from '#lib/structures'; -import { Events, PermissionLevels, type GuildMessage } from '#lib/types'; -import { getModeration, sendTemporaryMessage } from '#utils/functions'; -import { getImage } from '#utils/util'; -import { ApplyOptions } from '@sapphire/decorators'; -import { CommandOptionsRunTypeEnum } from '@sapphire/framework'; -import { PermissionFlagsBits } from 'discord.js'; - -@ApplyOptions({ - description: LanguageKeys.Commands.Moderation.ReasonDescription, - detailedDescription: LanguageKeys.Commands.Moderation.ReasonExtended, - permissionLevel: PermissionLevels.Moderator, - requiredClientPermissions: [PermissionFlagsBits.EmbedLinks], - runIn: [CommandOptionsRunTypeEnum.GuildAny] -}) -export class UserCommand extends SkyraCommand { - public override async messageRun(message: GuildMessage, args: SkyraCommand.Args) { - const cases = await args - .pick('case') - .then((value) => [value]) - .catch(() => args.pick('range', { maximum: 50 })); - - const moderation = getModeration(message.guild); - const entries = await moderation.fetch(cases); - if (!entries.size) { - this.error(LanguageKeys.Commands.Moderation.ModerationCaseNotExists, { count: cases.length }); - } - - const reason = await args.rest('string'); - const imageURL = getImage(message); - const { moderations } = this.container.db; - await moderations - .createQueryBuilder() - .update() - .where('guild_id = :guild', { guild: message.guild.id }) - .andWhere('case_id IN (:...ids)', { ids: [...entries.keys()] }) - .set({ reason, imageURL }) - .execute(); - await moderation.fetchChannelMessages(); - for (const entry of entries.values()) { - const clone = entry.clone(); - entry.setReason(reason).setImageURL(imageURL); - this.container.client.emit(Events.ModerationEntryEdit, clone, entry); - } - - return sendTemporaryMessage( - message, - args.t(LanguageKeys.Commands.Moderation.ReasonUpdated, { - entries: cases, - newReason: reason, - count: cases.length - }) - ); - } -} diff --git a/src/commands/Moderation/Utilities/time.ts b/src/commands/Moderation/Utilities/time.ts deleted file mode 100644 index 3821988a64e..00000000000 --- a/src/commands/Moderation/Utilities/time.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { ModerationEntity } from '#lib/database'; -import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { SkyraCommand } from '#lib/structures'; -import { PermissionLevels, type GuildMessage } from '#lib/types'; -import { seconds, years } from '#utils/common'; -import { getModeration, getSecurity } from '#utils/functions'; -import { SchemaKeys, TypeVariation } from '#utils/moderationConstants'; -import { getTag } from '#utils/util'; -import { ApplyOptions } from '@sapphire/decorators'; -import { Args, CommandOptionsRunTypeEnum } from '@sapphire/framework'; -import { send } from '@sapphire/plugin-editable-commands'; -import { PermissionFlagsBits, type User } from 'discord.js'; - -@ApplyOptions({ - description: LanguageKeys.Commands.Moderation.TimeDescription, - detailedDescription: LanguageKeys.Commands.Moderation.TimeExtended, - permissionLevel: PermissionLevels.Moderator, - runIn: [CommandOptionsRunTypeEnum.GuildAny] -}) -export class UserCommand extends SkyraCommand { - public override async messageRun(message: GuildMessage, args: SkyraCommand.Args) { - const cancel = await args.pick(UserCommand.cancel).catch(() => false); - const caseId = await args.pick('case'); - - const moderation = getModeration(message.guild); - const entry = await moderation.fetch(caseId); - if (!entry) this.error(LanguageKeys.Commands.Moderation.ModerationCaseNotExists, { count: 1 }); - if (!cancel && entry.temporaryType) this.error(LanguageKeys.Commands.Moderation.TimeTimed); - - const user = await entry.fetchUser(); - await this.validateAction(message, entry, user); - const task = this.container.schedule.queue.find( - (tk) => tk.data && tk.data[SchemaKeys.Case] === entry.caseId && tk.data[SchemaKeys.Guild] === entry.guild.id - )!; - - if (cancel) { - if (!task) this.error(LanguageKeys.Commands.Moderation.TimeNotScheduled); - - await moderation.fetchChannelMessages(); - await entry.edit({ - duration: null, - moderatorId: message.author.id - }); - - this.error(LanguageKeys.Commands.Moderation.TimeAborted, { title: entry.title }); - } - - if (entry.appealType || entry.invalidated) { - this.error(LanguageKeys.Commands.Moderation.ModerationLogAppealed); - } - - if (task) { - this.error(LanguageKeys.Commands.Moderation.ModerationTimed, { - remaining: (task.data.timestamp as number) - Date.now() - }); - } - - const duration = await args.rest('timespan', { minimum: seconds(1), maximum: years(5) }); - await moderation.fetchChannelMessages(); - await entry.edit({ - duration, - moderatorId: message.author.id - }); - - const content = args.t(LanguageKeys.Commands.Moderation.TimeScheduled, { - title: entry.title, - userId: user.id, - userTag: getTag(user), - time: duration! - }); - return send(message, content); - } - - private async validateAction(message: GuildMessage, entry: ModerationEntity, user: User) { - switch (entry.type) { - case TypeVariation.Ban: - return this.checkBan(message, user); - case TypeVariation.Mute: - return this.checkMute(message, user); - case TypeVariation.VoiceMute: - return this.checkVMute(message, user); - case TypeVariation.Warning: - // TODO(kyranet): Add checks for restrictions - case TypeVariation.RestrictedAttachment: - case TypeVariation.RestrictedEmbed: - case TypeVariation.RestrictedEmoji: - case TypeVariation.RestrictedReaction: - case TypeVariation.RestrictedVoice: - return; - default: - this.error(LanguageKeys.Commands.Moderation.TimeUnsupportedType); - } - } - - private async checkBan(message: GuildMessage, user: User) { - if (!message.guild.members.me!.permissions.has(PermissionFlagsBits.BanMembers)) { - this.error(LanguageKeys.Commands.Moderation.UnbanMissingPermission); - } - - if (!(await getSecurity(message.guild).actions.userIsBanned(user))) { - this.error(LanguageKeys.Commands.Moderation.GuildBansNotFound); - } - } - - private async checkMute(message: GuildMessage, user: User) { - if (!message.guild.members.me!.permissions.has(PermissionFlagsBits.ManageRoles)) { - this.error(LanguageKeys.Commands.Moderation.UnmuteMissingPermission); - } - - if (!(await getSecurity(message.guild).actions.userIsMuted(user))) { - this.error(LanguageKeys.Commands.Moderation.MuteUserNotMuted); - } - } - - private async checkVMute(message: GuildMessage, user: User) { - if (!message.guild.members.me!.permissions.has(PermissionFlagsBits.MuteMembers)) { - this.error(LanguageKeys.Commands.Moderation.VmuteMissingPermission); - } - - if (!(await getSecurity(message.guild).actions.userIsVoiceMuted(user))) { - this.error(LanguageKeys.Commands.Moderation.VmuteUserNotMuted); - } - } - - private static cancel = Args.make((parameter, { argument }) => { - if (parameter.toLowerCase() === 'cancel') return Args.ok(true); - return Args.error({ argument, parameter }); - }); -} diff --git a/src/commands/Moderation/addrole.ts b/src/commands/Moderation/addrole.ts index 0cb11e3b277..40ff6d67758 100644 --- a/src/commands/Moderation/addrole.ts +++ b/src/commands/Moderation/addrole.ts @@ -1,42 +1,38 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationCommand, type HandledCommandContext } from '#lib/moderation'; +import { ModerationCommand } from '#lib/moderation'; import { PermissionLevels, type GuildMessage } from '#lib/types'; -import { years } from '#utils/common'; -import { getSecurity } from '#utils/functions'; -import { getImage } from '#utils/util'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; import { PermissionFlagsBits, type Role } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.RoleAdd; +type ValueType = null; + +@ApplyOptions>({ aliases: ['ar'], description: LanguageKeys.Commands.Moderation.AddRoleDescription, detailedDescription: LanguageKeys.Commands.Moderation.AddRoleExtended, - optionalDuration: true, permissionLevel: PermissionLevels.Administrator, requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - requiredMember: true + type: TypeVariation.RoleAdd, + requiredMember: true, + actionStatusKey: LanguageKeys.Moderation.ActionIsActiveRole }) -export class UserModerationCommand extends ModerationCommand { - protected override async resolveOverloads(args: ModerationCommand.Args) { +export class UserModerationCommand extends ModerationCommand { + protected override async resolveParameters(args: ModerationCommand.Args) { return { - targets: await args.repeat('user', { times: 10 }), + targets: await this.resolveParametersUser(args), role: await args.pick('roleName'), - duration: this.optionalDuration ? await args.pick('timespan', { minimum: 0, maximum: years(5) }).catch(() => null) : null, - reason: args.finished ? null : await args.rest('string') + duration: await this.resolveParametersDuration(args), + reason: await this.resolveParametersReason(args) }; } - protected async handle(message: GuildMessage, context: HandledCommandContext & { role: Role }) { - return getSecurity(message.guild).actions.addRole( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message), - duration: context.duration - }, - context.role, - await this.getTargetDM(message, context.args, context.target) - ); + protected override getHandleDataContext(_message: GuildMessage, context: HandlerParameters) { + return context.role; } } + +interface HandlerParameters extends ModerationCommand.HandlerParameters { + role: Role; +} diff --git a/src/commands/Moderation/ban.ts b/src/commands/Moderation/ban.ts index 1b8852000ea..05cfe28d139 100644 --- a/src/commands/Moderation/ban.ts +++ b/src/commands/Moderation/ban.ts @@ -1,48 +1,39 @@ import { GuildSettings, readSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { ModerationCommand } from '#lib/moderation'; -import { getModeration, getSecurity } from '#utils/functions'; +import type { GuildMessage } from '#lib/types'; +import { getModeration } from '#utils/functions'; import { TimeOptions, getSeconds } from '#utils/moderation-utilities'; -import type { Unlock } from '#utils/moderationConstants'; -import { getImage } from '#utils/util'; +import { TypeVariation, type Unlock } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.Ban; +type ValueType = Unlock | null; + +@ApplyOptions>({ aliases: ['b'], description: LanguageKeys.Commands.Moderation.BanDescription, detailedDescription: LanguageKeys.Commands.Moderation.BanExtended, - optionalDuration: true, options: TimeOptions, requiredClientPermissions: [PermissionFlagsBits.BanMembers], - requiredMember: false + type: TypeVariation.Ban }) -export class UserModerationCommand extends ModerationCommand { - public override async prehandle(...[message]: ArgumentTypes) { +export class UserModerationCommand extends ModerationCommand { + protected override async preHandle(message: GuildMessage) { return (await readSettings(message.guild, GuildSettings.Events.BanAdd)) ? { unlock: getModeration(message.guild).createLock() } : null; } - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.ban( - { - userId: context.target.id, - moderatorId: message.author.id, - duration: context.duration, - imageURL: getImage(message), - reason: context.reason - }, - getSeconds(context.args), - await this.getTargetDM(message, context.args, context.target) - ); + protected override getHandleDataContext(_message: GuildMessage, context: ModerationCommand.HandlerParameters) { + return getSeconds(context.args); } - public override posthandle(...[, { preHandled }]: ArgumentTypes['posthandle']>) { - if (preHandled) preHandled.unlock(); + protected override postHandle(_message: GuildMessage, { preHandled }: ModerationCommand.PostHandleParameters) { + preHandled?.unlock(); } - public override async checkModeratable(...[message, context]: ArgumentTypes['checkModeratable']>) { - const member = await super.checkModeratable(message, context); + protected override async checkTargetCanBeModerated(message: GuildMessage, context: ModerationCommand.HandlerParameters) { + const member = await super.checkTargetCanBeModerated(message, context); if (member && !member.bannable) throw context.args.t(LanguageKeys.Commands.Moderation.BanNotBannable); return member; } diff --git a/src/commands/Moderation/kick.ts b/src/commands/Moderation/kick.ts index 05ad3df3071..f72efc35b7d 100644 --- a/src/commands/Moderation/kick.ts +++ b/src/commands/Moderation/kick.ts @@ -1,45 +1,36 @@ import { GuildSettings, readSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { ModerationCommand } from '#lib/moderation'; -import { getModeration, getSecurity } from '#utils/functions'; -import type { Unlock } from '#utils/moderationConstants'; -import { getImage } from '#utils/util'; +import type { GuildMessage } from '#lib/types'; +import { getModeration } from '#utils/functions'; +import { TypeVariation, type Unlock } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.Kick; +type ValueType = Unlock | null; + +@ApplyOptions>({ aliases: ['k'], description: LanguageKeys.Commands.Moderation.KickDescription, detailedDescription: LanguageKeys.Commands.Moderation.KickExtended, requiredClientPermissions: [PermissionFlagsBits.KickMembers], - requiredMember: true + requiredMember: true, + type: TypeVariation.Kick }) -export class UserModerationCommand extends ModerationCommand { - public override async prehandle(...[message]: ArgumentTypes) { +export class UserModerationCommand extends ModerationCommand { + protected override async preHandle(message: GuildMessage) { return (await readSettings(message.guild, GuildSettings.Channels.Logs.MemberRemove)) ? { unlock: getModeration(message.guild).createLock() } : null; } - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.kick( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message) - }, - await this.getTargetDM(message, context.args, context.target) - ); - } - - public override posthandle(...[, { preHandled }]: ArgumentTypes['posthandle']>) { - if (preHandled) preHandled.unlock(); + protected override postHandle(_message: GuildMessage, { preHandled }: ModerationCommand.PostHandleParameters) { + preHandled?.unlock(); } - public override async checkModeratable(...[message, context]: ArgumentTypes) { - const member = await super.checkModeratable(message, context); + protected override async checkTargetCanBeModerated(message: GuildMessage, context: ModerationCommand.HandlerParameters) { + const member = await super.checkTargetCanBeModerated(message, context); if (member && !member.kickable) throw context.args.t(LanguageKeys.Commands.Moderation.KickNotKickable); return member; } diff --git a/src/commands/Moderation/mute.ts b/src/commands/Moderation/mute.ts index d104b6f06ec..5440359d9bb 100644 --- a/src/commands/Moderation/mute.ts +++ b/src/commands/Moderation/mute.ts @@ -1,34 +1,17 @@ -import { GuildSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SetUpModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { ModerationSetupRestriction } from '#utils/Security/ModerationActions'; -import { getImage } from '#utils/util'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.Mute; +type ValueType = null; + +@ApplyOptions>({ aliases: ['m'], description: LanguageKeys.Commands.Moderation.MuteDescription, detailedDescription: LanguageKeys.Commands.Moderation.MuteExtended, - optionalDuration: true, requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - requiredMember: true, - roleKey: GuildSettings.Roles.Muted, - setUpKey: ModerationSetupRestriction.All + type: TypeVariation.Mute }) -export class UserSetUpModerationCommand extends SetUpModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.mute( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message), - duration: context.duration - }, - await this.getTargetDM(message, context.args, context.target) - ); - } -} +export class UserSetUpModerationCommand extends SetUpModerationCommand {} diff --git a/src/commands/Moderation/removerole.ts b/src/commands/Moderation/removerole.ts index d7f3255636a..dcf900e5ede 100644 --- a/src/commands/Moderation/removerole.ts +++ b/src/commands/Moderation/removerole.ts @@ -1,42 +1,38 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationCommand, type HandledCommandContext } from '#lib/moderation'; +import { ModerationCommand } from '#lib/moderation'; import { PermissionLevels, type GuildMessage } from '#lib/types'; -import { years } from '#utils/common'; -import { getSecurity } from '#utils/functions'; -import { getImage } from '#utils/util'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; import { PermissionFlagsBits, type Role } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.RoleRemove; +type ValueType = null; + +@ApplyOptions>({ aliases: ['rro'], description: LanguageKeys.Commands.Moderation.RemoveRoleDescription, detailedDescription: LanguageKeys.Commands.Moderation.RemoveRoleExtended, - optionalDuration: true, permissionLevel: PermissionLevels.Administrator, requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - requiredMember: true + requiredMember: true, + type: TypeVariation.RoleRemove, + actionStatusKey: LanguageKeys.Moderation.ActionIsNotActiveRole }) -export class UserModerationCommand extends ModerationCommand { - protected override async resolveOverloads(args: ModerationCommand.Args) { +export class UserModerationCommand extends ModerationCommand { + protected override async resolveParameters(args: ModerationCommand.Args) { return { - targets: await args.repeat('user', { times: 10 }), + targets: await this.resolveParametersUser(args), role: await args.pick('roleName'), - duration: this.optionalDuration ? await args.pick('timespan', { minimum: 0, maximum: years(5) }).catch(() => null) : null, - reason: args.finished ? null : await args.rest('string') + duration: await this.resolveParametersDuration(args), + reason: await this.resolveParametersReason(args) }; } - protected async handle(message: GuildMessage, context: HandledCommandContext & { role: Role }) { - return getSecurity(message.guild).actions.removeRole( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message), - duration: context.duration - }, - context.role, - await this.getTargetDM(message, context.args, context.target) - ); + protected override getHandleDataContext(_message: GuildMessage, context: HandlerParameters) { + return context.role; } } + +interface HandlerParameters extends ModerationCommand.HandlerParameters { + role: Role; +} diff --git a/src/commands/Moderation/setnickname.ts b/src/commands/Moderation/setnickname.ts index 645c44bd70b..653ce6c7f21 100644 --- a/src/commands/Moderation/setnickname.ts +++ b/src/commands/Moderation/setnickname.ts @@ -1,41 +1,40 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationCommand, type HandledCommandContext } from '#lib/moderation'; +import { ModerationCommand } from '#lib/moderation'; import type { GuildMessage } from '#lib/types'; -import { years } from '#utils/common'; -import { getSecurity } from '#utils/functions'; -import { getImage } from '#utils/util'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.SetNickname; +type ValueType = null; + +@ApplyOptions>({ aliases: ['sn'], description: LanguageKeys.Commands.Moderation.SetNicknameDescription, detailedDescription: LanguageKeys.Commands.Moderation.SetNicknameExtended, - optionalDuration: true, requiredClientPermissions: [PermissionFlagsBits.ManageNicknames], + type: TypeVariation.SetNickname, requiredMember: true }) -export class UserModerationCommand extends ModerationCommand { - protected override async resolveOverloads(args: ModerationCommand.Args) { +export class UserModerationCommand extends ModerationCommand { + protected override async resolveParameters(args: ModerationCommand.Args) { return { - targets: await args.repeat('user', { times: 10 }), + targets: await this.resolveParametersUser(args), nickname: args.finished ? null : await args.pick('string'), - duration: this.optionalDuration ? await args.pick('timespan', { minimum: 0, maximum: years(5) }).catch(() => null) : null, - reason: args.finished ? null : await args.rest('string') + duration: await this.resolveParametersDuration(args), + reason: await this.resolveParametersReason(args) }; } - protected async handle(message: GuildMessage, context: HandledCommandContext & { nickname: string }) { - return getSecurity(message.guild).actions.setNickname( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message), - duration: context.duration - }, - context.nickname, - await this.getTargetDM(message, context.args, context.target) - ); + protected override getHandleDataContext(_message: GuildMessage, context: HandlerParameters) { + return context.nickname; + } + + protected override getActionStatusKey(context: HandlerParameters) { + return context.nickname === null ? LanguageKeys.Moderation.ActionIsNotActiveNickname : LanguageKeys.Moderation.ActionIsActiveNickname; } } + +interface HandlerParameters extends ModerationCommand.HandlerParameters { + nickname: string | null; +} diff --git a/src/commands/Moderation/softban.ts b/src/commands/Moderation/softban.ts index 697eb7dda88..5bfbd8aca9b 100644 --- a/src/commands/Moderation/softban.ts +++ b/src/commands/Moderation/softban.ts @@ -1,48 +1,41 @@ import { GuildSettings, readSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { ModerationCommand } from '#lib/moderation'; -import { getModeration, getSecurity } from '#utils/functions'; +import type { GuildMessage } from '#lib/types'; +import { getModeration } from '#utils/functions'; import { TimeOptions, getSeconds } from '#utils/moderation-utilities'; -import type { Unlock } from '#utils/moderationConstants'; -import { getImage } from '#utils/util'; +import { TypeVariation, type Unlock } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.Softban; +type ValueType = Unlock | null; + +@ApplyOptions>({ aliases: ['sb'], description: LanguageKeys.Commands.Moderation.SoftBanDescription, detailedDescription: LanguageKeys.Commands.Moderation.SoftBanExtended, options: TimeOptions, requiredClientPermissions: [PermissionFlagsBits.BanMembers], - requiredMember: false + requiredMember: false, + type: TypeVariation.Softban }) -export class UserModerationCommand extends ModerationCommand { - public override async prehandle(...[message]: ArgumentTypes) { +export class UserModerationCommand extends ModerationCommand { + public override async preHandle(message: GuildMessage) { const [banAdd, banRemove] = await readSettings(message.guild, [GuildSettings.Events.BanAdd, GuildSettings.Events.BanRemove]); return banAdd || banRemove ? { unlock: getModeration(message.guild).createLock() } : null; } - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.softBan( - { - userId: context.target.id, - moderatorId: message.author.id, - duration: context.duration, - reason: context.reason, - imageURL: getImage(message) - }, - getSeconds(context.args), - await this.getTargetDM(message, context.args, context.target) - ); + public override getHandleDataContext(_message: GuildMessage, context: ModerationCommand.HandlerParameters) { + return getSeconds(context.args); } - public override posthandle(...[, { preHandled }]: ArgumentTypes['posthandle']>) { - if (preHandled) preHandled.unlock(); + public override postHandle(_message: GuildMessage, { preHandled }: ModerationCommand.PostHandleParameters) { + preHandled?.unlock(); } - public override async checkModeratable(...[message, context]: ArgumentTypes) { - const member = await super.checkModeratable(message, context); + public override async checkTargetCanBeModerated(message: GuildMessage, context: ModerationCommand.HandlerParameters) { + const member = await super.checkTargetCanBeModerated(message, context); if (member && !member.bannable) throw context.args.t(LanguageKeys.Commands.Moderation.BanNotBannable); return member; } diff --git a/src/commands/Moderation/unban.ts b/src/commands/Moderation/unban.ts index a2ea94bc3c0..0d47d8f4efc 100644 --- a/src/commands/Moderation/unban.ts +++ b/src/commands/Moderation/unban.ts @@ -2,62 +2,29 @@ import { GuildSettings, readSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { ModerationCommand } from '#lib/moderation'; import type { GuildMessage } from '#lib/types'; -import { getModeration, getSecurity } from '#utils/functions'; -import type { Unlock } from '#utils/moderationConstants'; -import { getImage } from '#utils/util'; +import { getModeration } from '#utils/functions'; +import { TypeVariation, type Unlock } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import { Result } from '@sapphire/framework'; -import { resolveKey } from '@sapphire/plugin-i18next'; -import type { ArgumentTypes } from '@sapphire/utilities'; import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.Ban; +type ValueType = Unlock | null; + +@ApplyOptions>({ aliases: ['ub'], description: LanguageKeys.Commands.Moderation.UnbanDescription, detailedDescription: LanguageKeys.Commands.Moderation.UnbanExtended, requiredClientPermissions: [PermissionFlagsBits.BanMembers], - requiredMember: false + requiredMember: false, + type: TypeVariation.Ban, + isUndoAction: true }) -export class UserModerationCommand extends ModerationCommand { - public override async prehandle(message: GuildMessage) { - const result = await Result.fromAsync(message.guild.bans.fetch()); - const bans = result.map((value) => value.map((ban) => ban.user.id)).unwrapOr(null); - - // If the fetch failed, throw an error saying that the fetch failed: - if (bans === null) { - throw await resolveKey(message, LanguageKeys.System.FetchBansFail); - } - - // If there were no bans, throw an error saying that the ban list is empty: - if (bans.length === 0) { - throw await resolveKey(message, LanguageKeys.Commands.Moderation.GuildBansEmpty); - } - - return { - bans, - unlock: (await readSettings(message.guild, GuildSettings.Events.BanRemove)) ? getModeration(message.guild).createLock() : null - }; - } - - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.unBan( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message), - duration: context.duration - }, - await this.getTargetDM(message, context.args, context.target) - ); - } - - public override posthandle(...[, { preHandled }]: ArgumentTypes['posthandle']>) { - if (preHandled) preHandled.unlock(); +export class UserModerationCommand extends ModerationCommand { + public override async preHandle(message: GuildMessage) { + return (await readSettings(message.guild, GuildSettings.Events.BanRemove)) ? { unlock: getModeration(message.guild).createLock() } : null; } - public override checkModeratable(...[message, context]: ArgumentTypes['checkModeratable']>) { - if (!context.preHandled.bans.includes(context.target.id)) throw context.args.t(LanguageKeys.Commands.Moderation.GuildBansNotFound); - return super.checkModeratable(message, context); + public override postHandle(_message: GuildMessage, { preHandled }: ModerationCommand.PostHandleParameters) { + preHandled?.unlock?.(); } } diff --git a/src/commands/Moderation/unmute.ts b/src/commands/Moderation/unmute.ts index 1430ffe8ec5..59a11fddf53 100644 --- a/src/commands/Moderation/unmute.ts +++ b/src/commands/Moderation/unmute.ts @@ -1,31 +1,16 @@ -import { GuildSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationCommand, SetUpModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { ModerationSetupRestriction } from '#utils/Security/ModerationActions'; -import { getImage } from '#utils/util'; +import { SetUpModerationCommand } from '#lib/moderation'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; -import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.Mute; +type ValueType = null; + +@ApplyOptions>({ aliases: ['um'], description: LanguageKeys.Commands.Moderation.UnmuteDescription, detailedDescription: LanguageKeys.Commands.Moderation.UnmuteExtended, - requiredClientPermissions: [PermissionFlagsBits.ManageRoles], - roleKey: GuildSettings.Roles.Muted, - setUpKey: ModerationSetupRestriction.All + type: TypeVariation.Mute, + isUndoAction: true }) -export class UserSetUpModerationCommand extends SetUpModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.unMute( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message) - }, - await this.getTargetDM(message, context.args, context.target) - ); - } -} +export class UserSetUpModerationCommand extends SetUpModerationCommand {} diff --git a/src/commands/Moderation/unwarn.ts b/src/commands/Moderation/unwarn.ts deleted file mode 100644 index f312c6d9016..00000000000 --- a/src/commands/Moderation/unwarn.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { GuildSettings, ModerationEntity, readSettings } from '#lib/database'; -import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationCommand, type HandledCommandContext } from '#lib/moderation'; -import type { GuildMessage } from '#lib/types'; -import { floatPromise } from '#utils/common'; -import { deleteMessage, getModeration, getSecurity } from '#utils/functions'; -import { TypeVariation } from '#utils/moderationConstants'; -import { getImage, getTag } from '#utils/util'; -import { ApplyOptions } from '@sapphire/decorators'; -import { send } from '@sapphire/plugin-editable-commands'; - -@ApplyOptions({ - aliases: ['uw', 'unwarning'], - description: LanguageKeys.Commands.Moderation.UnwarnDescription, - detailedDescription: LanguageKeys.Commands.Moderation.UnwarnExtended -}) -export class UserModerationCommand extends ModerationCommand { - public override async messageRun(message: GuildMessage, args: ModerationCommand.Args) { - const caseId = await args.pick('case'); - const reason = args.finished ? null : await args.rest('string'); - - const [autoDelete, messageDisplay, reasonDisplay] = await readSettings(message.guild, [ - GuildSettings.Messages.ModerationAutoDelete, - GuildSettings.Messages.ModerationMessageDisplay, - GuildSettings.Messages.ModerationReasonDisplay - ]); - - const entry = await getModeration(message.guild).fetch(caseId); - if (!entry || entry.type !== TypeVariation.Warning) { - this.error(LanguageKeys.Commands.Moderation.GuildWarnNotFound); - } - - const user = await entry.fetchUser(); - const unwarnLog = await this.handle(message, { args, target: user, reason, modlog: entry, duration: null, preHandled: null }); - - // If the server was configured to automatically delete messages, delete the command and return null. - if (autoDelete) { - if (message.deletable) floatPromise(deleteMessage(message)); - } - - if (messageDisplay) { - const originalReason = reasonDisplay ? unwarnLog.reason : null; - const content = args.t( - originalReason ? LanguageKeys.Commands.Moderation.ModerationOutputWithReason : LanguageKeys.Commands.Moderation.ModerationOutput, - { count: 1, range: unwarnLog.caseId, users: [`\`${getTag(user)}\``], reason: originalReason } - ); - - return send(message, content) as Promise; - } - - return null; - } - - public async handle(message: GuildMessage, context: HandledCommandContext & { modlog: ModerationEntity }) { - return getSecurity(message.guild).actions.unWarning( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message) - }, - context.modlog.caseId, - await this.getTargetDM(message, context.args, context.target) - ); - } -} diff --git a/src/commands/Moderation/vmute.ts b/src/commands/Moderation/vmute.ts index e16f0e883b9..4144ef6d216 100644 --- a/src/commands/Moderation/vmute.ts +++ b/src/commands/Moderation/vmute.ts @@ -1,36 +1,18 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; import { ModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { getImage } from '#utils/util'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.VoiceMute; +type ValueType = null; + +@ApplyOptions>({ aliases: ['vm'], description: LanguageKeys.Commands.Moderation.VmuteDescription, detailedDescription: LanguageKeys.Commands.Moderation.VmuteExtended, - optionalDuration: true, requiredClientPermissions: [PermissionFlagsBits.MuteMembers], - requiredMember: true + requiredMember: true, + type: TypeVariation.VoiceMute }) -export class UserModerationCommand extends ModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.voiceMute( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message), - duration: context.duration - }, - await this.getTargetDM(message, context.args, context.target) - ); - } - - public override async checkModeratable(...[message, context]: ArgumentTypes) { - const member = await super.checkModeratable(message, context); - if (member && member.voice.serverMute) throw context.args.t(LanguageKeys.Commands.Moderation.MuteMuted); - return member; - } -} +export class UserModerationCommand extends ModerationCommand {} diff --git a/src/commands/Moderation/voicekick.ts b/src/commands/Moderation/voicekick.ts index 8deb18625f3..7694c837279 100644 --- a/src/commands/Moderation/voicekick.ts +++ b/src/commands/Moderation/voicekick.ts @@ -1,34 +1,18 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; import { ModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { getImage } from '#utils/util'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.VoiceKick; +type ValueType = null; + +@ApplyOptions>({ aliases: ['vk', 'vkick'], description: LanguageKeys.Commands.Moderation.VoiceKickDescription, detailedDescription: LanguageKeys.Commands.Moderation.VoiceKickExtended, requiredClientPermissions: [PermissionFlagsBits.ManageChannels, PermissionFlagsBits.MoveMembers], - requiredMember: true + requiredMember: true, + type: TypeVariation.VoiceKick }) -export class UserModerationCommand extends ModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.voiceKick( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message) - }, - await this.getTargetDM(message, context.args, context.target) - ); - } - - public override async checkModeratable(...[message, context]: ArgumentTypes) { - const member = await super.checkModeratable(message, context); - if (member && !member.voice.channelId) throw context.args.t(LanguageKeys.Commands.Moderation.GuildMemberNotVoicechannel); - return member; - } -} +export class UserModerationCommand extends ModerationCommand {} diff --git a/src/commands/Moderation/vunmute.ts b/src/commands/Moderation/vunmute.ts index 9dd9361a625..c287ce0d7c5 100644 --- a/src/commands/Moderation/vunmute.ts +++ b/src/commands/Moderation/vunmute.ts @@ -1,34 +1,19 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; import { ModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { getImage } from '#utils/util'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; import { PermissionFlagsBits } from 'discord.js'; -@ApplyOptions({ +type Type = TypeVariation.VoiceMute; +type ValueType = null; + +@ApplyOptions>({ aliases: ['uvm', 'vum', 'unvmute'], description: LanguageKeys.Commands.Moderation.VunmuteDescription, detailedDescription: LanguageKeys.Commands.Moderation.VunmuteExtended, requiredClientPermissions: [PermissionFlagsBits.MuteMembers], - requiredMember: true + requiredMember: true, + type: TypeVariation.VoiceMute, + isUndoAction: true }) -export class UserModerationCommand extends ModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.unVoiceMute( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message) - }, - await this.getTargetDM(message, context.args, context.target) - ); - } - - public override async checkModeratable(...[message, context]: ArgumentTypes) { - const member = await super.checkModeratable(message, context); - if (member && !member.voice.serverMute) throw context.args.t(LanguageKeys.Commands.Moderation.VmuteUserNotMuted); - return member; - } -} +export class UserModerationCommand extends ModerationCommand {} diff --git a/src/commands/Moderation/warn.ts b/src/commands/Moderation/warn.ts index a5537b49c83..332646933b0 100644 --- a/src/commands/Moderation/warn.ts +++ b/src/commands/Moderation/warn.ts @@ -1,28 +1,16 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; import { ModerationCommand } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; -import { getImage } from '#utils/util'; +import { TypeVariation } from '#utils/moderationConstants'; import { ApplyOptions } from '@sapphire/decorators'; -import type { ArgumentTypes } from '@sapphire/utilities'; -@ApplyOptions({ +type Type = TypeVariation.Warning; +type ValueType = null; + +@ApplyOptions>({ aliases: ['w', 'warning'], description: LanguageKeys.Commands.Moderation.WarnDescription, detailedDescription: LanguageKeys.Commands.Moderation.WarnExtended, - optionalDuration: true, - requiredMember: true + requiredMember: true, + type: TypeVariation.Warning }) -export class UserModerationCommand extends ModerationCommand { - public async handle(...[message, context]: ArgumentTypes) { - return getSecurity(message.guild).actions.warning( - { - userId: context.target.id, - moderatorId: message.author.id, - reason: context.reason, - imageURL: getImage(message), - duration: context.duration - }, - await this.getTargetDM(message, context.args, context.target) - ); - } -} +export class UserModerationCommand extends ModerationCommand {} diff --git a/src/commands/Tools/whois.ts b/src/commands/Tools/whois.ts index 8b182235e2b..dc567b2b99d 100644 --- a/src/commands/Tools/whois.ts +++ b/src/commands/Tools/whois.ts @@ -18,7 +18,6 @@ import { ButtonStyle, ChatInputCommandInteraction, GuildMember, - MessageFlags, PermissionFlagsBits, UserContextMenuCommandInteraction, bold, @@ -78,7 +77,7 @@ export class UserCommand extends SkyraCommand { return interaction.reply({ ...this.sharedRun(getSupportedUserLanguageT(interaction), user, member), - flags: MessageFlags.Ephemeral + ephemeral: true }); } @@ -88,7 +87,7 @@ export class UserCommand extends SkyraCommand { return interaction.reply({ ...this.sharedRun(getSupportedUserLanguageT(interaction), user, member), - flags: MessageFlags.Ephemeral + ephemeral: true }); } diff --git a/src/config.ts b/src/config.ts index 07a74f7a4a0..87e050c5567 100644 --- a/src/config.ts +++ b/src/config.ts @@ -194,7 +194,7 @@ function parseInternationalizationOptions(): InternationalizationOptions { load: 'all', lng: 'en-US', fallbackLng: { - 'es-419': ['es-ES'], // Latin America Spanish falls back to Spain Spanish + 'es-419': ['es-ES', 'en-US'], // Latin America Spanish falls back to Spain Spanish default: ['en-US'] }, defaultNS: 'globals', diff --git a/src/languages/en-US/arguments.json b/src/languages/en-US/arguments.json index fc9f1ac698e..b7aad7b351a 100644 --- a/src/languages/en-US/arguments.json +++ b/src/languages/en-US/arguments.json @@ -26,6 +26,9 @@ "1", "+" ], + "caseNoEntries": "I could not resolve `{{parameter}}` to a case, as there are no moderation cases in this server!", + "caseUnknownEntry": "I could not resolve `{{parameter}}` to a case, make sure you typed its number correctly!", + "caseNotInThisGuild": "I somehow resolved `{{parameter}}` to a case, but it is not from this server!", "caseLatestOptions": [ "last", "latest" @@ -43,7 +46,7 @@ "durationFormats": "- `4h` (4 hours).\n- `20m5s` (20 minutes and 5 seconds).\n- `\"1w 2d 16h 40m 10s\"` (1 week, 2 days, 16 hours, 40 minutes, and 10 seconds).", "emojiError": "I could not resolve `{{parameter}}` to a valid emoji, are you sure you used a valid twemoji (e.g. 🌊) or an emoji (e.g. {{GREENTICK}})?", "floatError": "I could not resolve `{{parameter}}` to a number!", - "floatTooLarge": "The parameter `{{parameter}}` is too high! It needs to be less than {{maximum}}!", + "floatTooLarge": "The parameter `{{parameter}}` is too high! It needs to be at most {{maximum}}!", "floatTooSmall": "The parameter `{{parameter}}` is too low! It needs to be at least {{minimum}}!", "guildChannelError": "I could not resolve `{{parameter}}` to a channel from this server, please make sure you typed its name or ID correctly!", "guildChannelMismatchingError": "The parameter `{{parameter}}` resolved to an incompatible channel type in this server, please try another channel!", @@ -56,7 +59,7 @@ "guildVoiceChannelError": "I could not resolve `{{parameter}}` to a voice channel, please make sure you typed its name or ID correctly!", "hyperlinkError": "I could not resolve `{{parameter}}` to an hyperlink, they are usually formatted similarly as `https://discord.com`!", "integerError": "I could not resolve `{{parameter}}` to an integer!", - "integerTooLarge": "The parameter `{{parameter}}` is too high! It needs to be less than {{maximum}}!", + "integerTooLarge": "The parameter `{{parameter}}` is too high! It needs to be at most {{maximum}}!", "integerTooSmall": "The parameter `{{parameter}}` is too low! It needs to be at least {{minimum}}!", "invite": "I could not resolve `{{parameter}}` to a valid invite link, they have one of the following formats:\n\n- `https://discord​.gg/6gakFR2`.\n- `https://discord​.com/invite/6gakFR2`.\n- `https://discordapp​.com/invite/6gakFR2`.\n\n> **Tip**: You can omit the `https://` part, `discord​.gg/6gakFR2` is also accepted as a valid parameter.", "language": "I could not resolve `{{parameter}}` to a valid language code!\n**Hint**: the following are supported: {{possibles, list(conjunction)}}.\n\n> **Tip**: You can add more (or improve the existing ones) at !", @@ -66,7 +69,7 @@ "missing": "You need to write another parameter!\n\n> **Tip**: You can do `{{commandContext.commandPrefix}}help {{command.name}}` to find out how to use this command.", "newsChannel": "I could not resolve `{{parameter}}` to an announcement channel, please make sure you typed its name or ID correctly!\n\n> **Tip**: You can also mention it!", "numberError": "I could not resolve `{{parameter}}` to a number!", - "numberTooLarge": "The parameter `{{parameter}}` is too high! It needs to be less than {{maximum}}!", + "numberTooLarge": "The parameter `{{parameter}}` is too high! It needs to be at most {{maximum}}!", "numberTooSmall": "The parameter `{{parameter}}` is too low! It needs to be at least {{minimum}}!", "piece": "I could not resolve `{{parameter}}` to a piece! Make sure you typed its name or one of its aliases correctly!", "rangeInvalid": "`{{parameter}}` must be a number or a range of numbers.", diff --git a/src/languages/en-US/commands/case.json b/src/languages/en-US/commands/case.json new file mode 100644 index 00000000000..e6cb87b869a --- /dev/null +++ b/src/languages/en-US/commands/case.json @@ -0,0 +1,54 @@ +{ + "name": "case", + "description": "Manage or view moderation cases.", + "viewName": "view", + "viewDescription": "Retrieve a moderation case's information.", + "archiveName": "archive", + "archiveDescription": "Archive a moderation case.", + "deleteName": "delete", + "deleteDescription": "Delete a moderation case.", + "editName": "edit", + "editDescription": "Edit a moderation case.", + "listName": "list", + "listDescription": "List the moderation cases.", + "optionsCaseName": "case", + "optionsCaseDescription": "The number of the moderation case.", + "optionsReasonName": "reason", + "optionsReasonDescription": "The new reason for the moderation case.", + "optionsDurationName": "duration", + "optionsDurationDescription": "The new duration for the moderation case.", + "optionsUserName": "user", + "optionsUserDescription": "The user to filter the moderation cases by.", + "optionsOverviewName": "overview", + "optionsOverviewDescription": "Whether or not to show the overview of the moderation cases.", + "optionsTypeName": "type", + "optionsTypeDescription": "The type to filter the moderation cases by.", + "optionsPendingOnlyName": "pending-only", + "optionsPendingOnlyDescription": "Whether or not to show only the pending moderation cases.", + "optionsShowName": "show", + "optionsShowDescription": "Whether or not to show the response publicly.", + "timeNotAllowed": "The type of the moderation case (**{{type}}**) does not allow for a duration.", + "timeNotAllowedInCompletedEntries": "The moderation case `{{caseId}}` has already been completed and cannot be edited.", + "timeTooEarly": "The duration of the moderation case would end before it starts ({{time}}). The duration starts at {{start}}.", + "listEmpty": "There are no moderation cases with the selected filters.", + "listDetailsTitle_one": "There is 1 entry.", + "listDetailsTitle_other": "There are {{count}} entries.", + "listDetailsModerator": "{{emoji}} **Moderator:** {{mention}} ({{userId}})", + "listDetailsUser": "{{emoji}} **User:** {{mention}} ({{userId}})", + "listDetailsExpires": "{{emoji}} **Expires {{time}}**", + "listOverviewFooter": "This server has {{warnings}}, {{mutes}}, {{timeouts}}, {{kicks}}, and {{bans}}", + "listOverviewFooterUser": "This user has {{warnings}}, {{mutes}}, {{timeouts}}, {{kicks}}, and {{bans}}", + "listOverviewFooterWarning_one": "{{count}} warning", + "listOverviewFooterWarning_other": "{{count}} warnings", + "listOverviewFooterMutes_one": "{{count}} mute", + "listOverviewFooterMutes_other": "{{count}} mutes", + "listOverviewFooterTimeouts_one": "{{count}} timeout", + "listOverviewFooterTimeouts_other": "{{count}} timeouts", + "listOverviewFooterKicks_one": "{{count}} kick", + "listOverviewFooterKicks_other": "{{count}} kicks", + "listOverviewFooterBans_one": "{{count}} ban", + "listOverviewFooterBans_other": "{{count}} bans", + "editSuccess": "Successfully edited case {{caseId}}.", + "archiveSuccess": "Successfully archived case {{caseId}}.", + "deleteSuccess": "Successfully deleted case {{caseId}}." +} diff --git a/src/languages/en-US/commands/moderation.json b/src/languages/en-US/commands/moderation.json index 431e6dd2ec4..4e191651685 100644 --- a/src/languages/en-US/commands/moderation.json +++ b/src/languages/en-US/commands/moderation.json @@ -1,39 +1,8 @@ { "permissions": "Permissions for {{username}} ({{id}})", "permissionsAll": "All Permissions", - "timeTimed": "The selected moderation case has already been timed.", - "timeUnsupportedType": "The type of action for the selected case cannot be reverse, therefore this action is unsupported.", - "timeNotScheduled": "This task is not scheduled.", - "timeAborted": "Successfully aborted the schedule for {{title}}", - "timeScheduled": "{{GREENTICK}} Successfully scheduled a moderation action type **{{title}}** for the user {{userTag}} ({{userId}}) with a duration of {{time, duration}}", "slowmodeSet": "The cooldown for this channel has been set to {{cooldown, duration}}.", "slowmodeReset": "The cooldown for this channel has been reset.", - "timeDescription": "Set the timer.", - "timeExtended": { - "usages": [ - "Case Duration", - "cancel Case Duration" - ], - "extendedHelp": "Updates the timer for a moderation case..", - "explainedUsage": [ - [ - "Cancel", - "Whether or not you want to cancel the timer. Defaults to \"no\"." - ], - [ - "Case", - "The case you want to update" - ], - [ - "Duration", - "The timer, ignored if `cancel` was defined." - ] - ], - "examples": [ - "cancel 1234", - "1234 6h" - ] - }, "banNotBannable": "The target is not bannable for me.", "dehoistStarting": "I will start dehoisting {{count}} members...", "dehoistProgress": "Dehoisted {{count}} members so far! ({{percentage}}%)", @@ -60,17 +29,8 @@ "pruneInvalidPosition": "{{REDCROSS}} Position must be one of \"before\" or \"after\".", "pruneNoDeletes": "No message has been deleted, either no message match the filter or they are over 14 days old.", "pruneLogHeader": "The following messages have been generated by request of a moderator.\nThe date formatting is of \"$t(globals:dateFormat) hh:mm:ss\".", - "pruneLogMessage_one": "{{count}} message deleted in {{channel}} by {{author}}.", - "pruneLogMessage_other": "{{count}} messages deleted in {{channel}} by {{author}}.", - "reasonNotExists": "The selected modlog doesn't seem to exist.", - "reasonUpdated_one": "{{GREENTICK}} Updated {{count}} case\n └─ **Set its reason to:** {{newReason}}", - "reasonUpdated_other": "{{GREENTICK}} Updated {{count}} cases\n └─ **Set their reasons to:** {{newReason}}", "toggleModerationDmToggledEnabled": "{{GREENTICK}} Successfully enabled moderation DMs.", "toggleModerationDmToggledDisabled": "{{GREENTICK}} Successfully disabled moderation DMs", - "unbanMissingPermission": "I will need the **{{BAN_MEMBERS, permissions}}** permission to be able to unban.", - "unmuteMissingPermission": "I will need the **{{MANAGE_ROLES, permissions}}** permission to be able to unmute.", - "vmuteMissingPermission": "I will need the **{{MUTE_MEMBERS, permissions}}** permission to be able to voice unmute.", - "vmuteUserNotMuted": "This user is not voice muted.", "moderationOutput_one": "{{GREENTICK}} Created case {{range}} | {{users, list(conjunction)}}.", "moderationOutput_other": "{{GREENTICK}} Created cases {{range}} | {{users, list(conjunction)}}.", "moderationOutputWithReason_one": "{{GREENTICK}} Created case {{range}} | {{users, list(conjunction)}}.\nWith the reason of: {{reason}}", @@ -82,96 +42,6 @@ "moderationDmDescriptionWithReason": "**❯ Server**: {{guild}}\n**❯ Type**: {{title}}\n**❯ Reason**: {{reason}}", "moderationDmDescriptionWithDuration": "**❯ Server**: {{guild}}\n**❯ Type**: {{title}}\n**❯ Duration**: {{duration, duration}}\n**❯ Reason**: None specified", "moderationDmDescriptionWithReasonWithDuration": "**❯ Server**: {{guild}}\n**❯ Type**: {{title}}\n**❯ Duration**: {{duration, duration}}\n**❯ Reason**: {{reason}}", - "historyDescription": "Display the count of moderation cases from this server or from a user.", - "historyExtended": { - "usages": [ - "", - "details", - "details User", - "overview User" - ], - "extendedHelp": "This command shows the amount of bans, mutes, kicks, and warnings, including temporary, that have not been appealed.", - "explainedUsage": [ - [ - "details/overview", - "Whether to get a detailed view or just a summary. Defaults to \"overview\"." - ], - [ - "User", - "The user for whom to get the information. Defaults to you yourself." - ] - ], - "examples": [ - "", - "overview", - "details", - "@Pete", - "details @Pete", - "overview Pete" - ] - }, - "historyFooterNew": "This user has {{warnings}} {{warningsText}}, {{mutes}} {{mutesText}}, {{kicks}} {{kicksText}}, and {{bans}} {{bansText}}", - "historyFooterWarning_one": "warning", - "historyFooterWarning_other": "warnings", - "historyFooterMutes_one": "mute", - "historyFooterMutes_other": "mutes", - "historyFooterKicks_one": "kick", - "historyFooterKicks_other": "kicks", - "historyFooterBans_one": "ban", - "historyFooterBans_other": "bans", - "moderationsDescription": "List all running moderation logs from this server.", - "moderationsExtended": { - "usages": [ - "", - "mutes/warnings/all", - "User", - "mutes/warnings/all User" - ], - "extendedHelp": "This command shows you all the temporary moderation actions that are still running. This command uses a reaction-based menu and requires the permission **{{MANAGE_MESSAGES, permissions}}** to execute correctly.", - "explainedUsage": [ - [ - "mutes/warnings/all", - "Whether to get just the mutes, just the warnings or everything. When providing either `mutes` or `warnings` then all moderations of that type are shown, not just the temporary ones." - ], - [ - "User", - "The user for whom to get the information. Defaults to all users." - ] - ], - "examples": [ - "", - "@Pete", - "mutes @Pete", - "warnings" - ] - }, - "moderationsEmpty": "There are no active moderations that will expire at some future date or time. If you want to see all moderations in this server use: `{{prefix}}history`.", - "moderationsAmount_one": "There is 1 entry.", - "moderationsAmount_other": "There are {{count}} entries.", - "mutesDescription": "List all mutes from this server or from a user.", - "mutesExtended": { - "usages": [ - "", - "User" - ], - "extendedHelp": "This command shows either all mutes filed in this server, or all mutes filed in this server for a specific user.\nThis command uses a reaction-based menu and requires the permission **{{MANAGE_MESSAGES, permissions}}** to execute correctly.", - "examples": [ - "", - "@Pete" - ] - }, - "warningsDescription": "List all warnings from this server or from a user.", - "warningsExtended": { - "usages": [ - "", - "User" - ], - "extendedHelp": "This command shows either all warnings filed in this server, or all warnings filed in this server for a specific user.\nThis command uses a reaction-based menu and requires the permission **{{MANAGE_MESSAGES, permissions}}** to execute correctly.", - "examples": [ - "", - "@Pete" - ] - }, "slowmodeDescription": "Set the channel's slowmode value in seconds.", "slowmodeExtended": { "usages": [ @@ -494,32 +364,6 @@ ], "reminder": "Due to a Discord limitation, bots cannot delete messages older than 14 days." }, - "caseDescription": "Get the information from a case by its index.", - "caseExtended": { - "usages": [ - "Case", - "Show Case", - "Delete Case" - ], - "extendedHelp": "You can also get the latest moderation case by specifying the case ID as `latest`", - "explainedUsage": [ - [ - "Show/Delete", - "Whether to show or delete a case. Defaults to show." - ], - [ - "Case", - "Number of the case ID to show or delete, or `latest` for the latest created case." - ] - ], - "examples": [ - "5", - "latest", - "delete 6", - "delete latest" - ] - }, - "caseDeleted": "{{GREENTICK}} Case {{case}} has been successfully deleted.", "permissionsDescription": "Check the permission for a member, or yours.", "permissionsExtended": { "usages": [ @@ -534,19 +378,6 @@ ], "extendedHelp": "Ideal if you want to know the what permissions are granted to a member when they have a certain set of roles." }, - "reasonDescription": "Edit the reason field from a moderation log case.", - "reasonExtended": { - "usages": [ - "Case/Range Reason" - ], - "extendedHelp": "This command allows moderation log case management, it allows moderators to update the reason.\nIf you want to modify multiple cases at once you provide a range.\nFor example `1..3` for the `` will edit cases 1, 2, and 3.\nAlternatively you can also give ranges with commas:\n`1,3..6` will result in cases 1, 3, 4, 5, and 6\n`1,2,3` will result in cases 1, 2, and 3", - "examples": [ - "420 Spamming all channels", - "419..421 Bad memes", - "1..3,4,7..9 Posting NSFW", - "latest Woops, I did a mistake!" - ] - }, "restrictAttachmentDescription": "Restrict a user from sending attachments in all channels.", "restrictAttachmentExtended": { "usages": [ diff --git a/src/languages/en-US/commands/shared.json b/src/languages/en-US/commands/shared.json index 559f6c83ca6..f28852fb132 100644 --- a/src/languages/en-US/commands/shared.json +++ b/src/languages/en-US/commands/shared.json @@ -1,3 +1,6 @@ { - "deprecatedMessage": "Message based commands are **deprecated**, and will be removed in the future. You should use the {{command}} slash command instead!" + "deprecatedMessage": "Message based commands are **deprecated**, and will be removed in the future. You should use the {{command}} slash command instead!", + "slashDetailed": { + "extendedHelp": "This is a chat input only command, please refer to the information from Discord's interface for how to use this command." + } } diff --git a/src/languages/en-US/errors.json b/src/languages/en-US/errors.json index 74d6d2b5c13..62a23f73732 100644 --- a/src/languages/en-US/errors.json +++ b/src/languages/en-US/errors.json @@ -1,4 +1,13 @@ { + "genericUnknownChannel": "I'm sorry, I tried to perform an action for a channel unknown to Discord and failed, this error has been reported to the developers.", + "genericUnknownGuild": "I'm sorry, I tried to perform an action for a server unknown to Discord and failed, this error has been reported to the developers.", + "genericUnknownMember": "I'm sorry, I tried to perform an action for a member unknown to Discord and failed, this error has been reported to the developers.", + "genericUnknownMessage": "I'm sorry, I tried to perform an action for a message unknown to Discord and failed, this error has been reported to the developers.", + "genericUnknownRole": "I'm sorry, I tried to perform an action for a role unknown to Discord and failed, this error has been reported to the developers.", + "genericMissingAccess": "I'm sorry, I tried to perform an action for a resource without the necessary permissions and failed, this error has been reported to the developers.", + "genericDiscordInternalServerError": "Oops, Discord broke itself, please try again later.", + "genericDiscordGateway": "Oops, the network is struggling to communicate with Discord, please try again later.", + "genericDiscordUnavailable": "Oops, Discord is currently unavailable, please try again later.", "guildBansEmpty": "There are no bans registered in this server.", "guildBansNotFound": "I tried and failed to find this user from the ban list. Are you certain this user is banned?", "guildMemberNotVoicechannel": "I cannot execute this action in a member that is not connected to a voice channel.", diff --git a/src/languages/en-US/moderation.json b/src/languages/en-US/moderation.json index 06c4ef3928f..19c297872ba 100644 --- a/src/languages/en-US/moderation.json +++ b/src/languages/en-US/moderation.json @@ -1,21 +1,42 @@ { + "typeBan": "Ban", + "typeKick": "Kick", + "typeMute": "Mute", + "typeRestrictedAttachment": "Attachment Restriction", + "typeRestrictedEmbed": "Embed Restriction", + "typeRestrictedEmoji": "Emoji Restriction", + "typeRestrictedReaction": "Reaction Restriction", + "typeRestrictedVoice": "Voice Restriction", + "typeRoleAdd": "Role Add", + "typeRoleRemove": "Role Remove", + "typeSetNickname": "Nickname Set", + "typeSoftban": "Softban", + "typeTimeout": "Timeout", + "typeVoiceKick": "Voice Kick", + "typeVoiceMute": "Voice Mute", + "typeWarning": "Warning", + "metadataUndo": "Remove {{name}}", + "metadataTemporary": "Temporary {{name}}", "caseNotExists_one": "{{REDCROSS}} I am sorry, but the selected moderation log case does not exist.", "caseNotExists_other": "{{REDCROSS}} I am sorry, but none of the selected moderation logs cases exist.", - "logAppealed": "{{REDCROSS}} I am sorry, but the selected moderation log has expired or cannot be cannot be made temporary.", - "logDescriptionTypeAndUser": "❯ **Type**: {{type}}\n❯ **User:** {{userTag}} ({{userId}})", - "logDescriptionWithReason": "❯ **Reason:** {{reason}}{{formattedDuration}}", - "logDescriptionWithoutReason": "❯ **Reason:** Please use `{{prefix}}reason {{caseId}} ` to set the reason.{{formattedDuration}}", - "logExpiresIn": "\n❯ **Expires In**: {{duration, duration}}", - "logFooter": "Case {{caseId}}", - "muteCannotManageRoles": "I must have **{{MANAGE_ROLES, permissions}}** permissions to be able to mute.", - "muteLowHierarchy": "I cannot mute a user which higher role hierarchy than me.", - "muteNotConfigured": "The muted role must be configured for this action to happen.", - "muteNotExists": "The specified user is not muted.", - "muteNotInMember": "The muted role is not set in the member.", + "embedUser": "{{tag}} ({{id}})", + "embedDescription": "❯ **Type:** {{type}}\n❯ **User:** {{user}}\n❯ **Reason:** {{reason}}", + "embedDescriptionTemporary": "❯ **Type:** {{type}}\n❯ **User:** {{user}}\n❯ **Expires {{time}}**\n❯ **Reason:** {{reason}}", + "embedReasonNotSet": "*Please use {{command}} to set a reason.*", + "embedFooter": "Case {{caseId}}", + "actionIsActive": "This moderation action is still active for this user.", + "actionIsNotActive": "This moderation action is not active for this user.", + "actionIsActiveRole": "This user already has the selected role.", + "actionIsNotActiveRole": "This user does not have the selected role.", + "actionIsActiveRestrictionRole": "This user already has the configured restriction role.", + "actionIsNotActiveRestrictionRole": "This user does not have the configured restriction role.", + "actionIsActiveNickname": "This user already has the selected nickname.", + "actionIsNotActiveNickname": "This user does not have the selected nickname.", + "actionTargetSelf": "You cannot perform this action on yourself. Why would you do that anyways?", + "actionTargetGuildOwner": "You cannot perform this action on the server owner.", + "actionTargetSkyra": "I... I cannot do that to myself! You broke my heart. 💔", + "actionTargetHigherHierarchySkyra": "This action cannot be performed on a member with a role position that is higher than or equal to mine.", + "actionTargetHigherHierarchyAuthor": "This action cannot be performed on a member with a role position that is higher than or equal to yours.", "restrictionNotConfigured": "The restriction role must be configured for this action to happen", - "roleHigher": "The selected member has a role position that is higher than or equal to yours.", - "roleHigherSkyra": "The selected member has a role position that is higher than or equal to mine.", - "success": "Successfully executed the command.", - "toSkyra": "Why... I thought you loved me! 💔", - "userSelf": "Why would you do that to yourself?" + "success": "Successfully executed the command." } diff --git a/src/languages/en-US/moderationActions.json b/src/languages/en-US/moderationActions.json index 2d176f3c6ef..6f617fc7483 100644 --- a/src/languages/en-US/moderationActions.json +++ b/src/languages/en-US/moderationActions.json @@ -1,19 +1,25 @@ { "actions": { - "addRole": "Added Role", + "addRole": "Role Add", "ban": "Ban", "kick": "Kick", "mute": "Mute", - "removeRole": "Remove Role", + "removeRole": "Role Remove", "restrictedAttachment": "Attachment Restriction", "restrictedEmbed": "Embed Restriction", + "restrictedEmoji": "Emoji Restriction", "restrictedReact": "Reaction Restriction", "restrictedVoice": "Voice Restriction", "setNickname": "Set Nickname", "softban": "Softban", "vkick": "Voice Kick", - "vmute": "Voice Mute" + "vmute": "Voice Mute", + "warning": "Warning" }, + "actionCannotManageRoles": "I cannot give or remove roles in this server.", + "actionRoleNotConfigured": "The role for this action is not configured.", + "actionRoleHigherPosition": "I cannot give or remove the role for this action because it has higher or equal hierarchy position than me.", + "actionRoleManaged": "I cannot give or remove the role for this action because it is managed by an integration.", "applyNoReason": "[Action] Applied {{action}}", "applyReason": "[Action] Applied {{action}} | Reason: {{reason}}", "requiredMember": "The user does not exist or is not in this server.", @@ -24,7 +30,6 @@ "setNicknameRemoved": "[Action] Removed Nickname | Reason: {{reason}}", "setNicknameSet": "[Action] Set Nickname | Reason: {{reason}}", "setupMuteExists": "**Aborting creating muted**: There is already a role called \"Muted\".", - "setupRestrictionExists": "**Aborting restriction role creation**: There is already one that exists.", "setupTooManyRoles": "**Aborting role creation**: There are 250 roles in this server, you need to delete one role.", "sharedRoleSetupAsk": "{{LOADING}} Can I modify {{channels}} channel to apply the role {{role}} the following permission: {{permissions}}?", "sharedRoleSetupNoMessage": "You did not input a message on time, cancelling the set up!", @@ -35,4 +40,4 @@ "softbanReason": "[Action] Applying Softban | Reason: {{reason}}", "unSoftbanNoReason": "[Action] Applied Softban.", "unSoftbanReason": "[Action] Applied Softban | Reason: {{reason}}" -} \ No newline at end of file +} diff --git a/src/lib/database/entities/ModerationEntity.ts b/src/lib/database/entities/ModerationEntity.ts index f55c49d5727..0f498c23cc6 100644 --- a/src/lib/database/entities/ModerationEntity.ts +++ b/src/lib/database/entities/ModerationEntity.ts @@ -1,25 +1,5 @@ -import { GuildSettings } from '#lib/database/keys'; -import { readSettings } from '#lib/database/settings'; import { kBigIntTransformer } from '#lib/database/utils/Transformers'; -import { LanguageKeys } from '#lib/i18n/languageKeys'; -import type { ModerationManager, ModerationManagerUpdateData } from '#lib/moderation'; -import { Events } from '#lib/types'; -import { minutes, years } from '#utils/common'; -import { - TypeMetadata, - TypeVariation, - TypeVariationAppealNames, - combineTypeData, - getMetadata, - hasMetadata, - type ModerationTypeAssets -} from '#utils/moderationConstants'; -import { getDisplayAvatar, getFullEmbedAuthor, getTag } from '#utils/util'; -import { EmbedBuilder } from '@discordjs/builders'; -import { UserError, container } from '@sapphire/framework'; -import { Duration, Time } from '@sapphire/time-utilities'; -import { isNullishOrZero, isNumber, tryParseURL, type NonNullObject } from '@sapphire/utilities'; -import { User } from 'discord.js'; +import type { TypeMetadata, TypeVariation } from '#utils/moderationConstants'; import { BaseEntity, Column, Entity, PrimaryColumn } from 'typeorm'; @Entity('moderation', { schema: 'public' }) @@ -34,7 +14,7 @@ export class ModerationEntity extends BaseEntity { public duration: number | null = null; @Column('json', { nullable: true, default: () => 'null' }) - public extraData: unknown[] | NonNullObject | null = null; + public extraData: object | null = null; @PrimaryColumn('varchar', { length: 19 }) public guildId: string = null!; @@ -56,355 +36,4 @@ export class ModerationEntity extends BaseEntity { @Column('smallint') public metadata!: TypeMetadata; - - #manager: ModerationManager = null!; - #moderator: User | null = null; - #user: User | null = null; - #timeout = Date.now() + minutes(15); - - public constructor(data?: Partial) { - super(); - - if (data) { - Object.assign(this, data); - this.metadata = ModerationEntity.getTypeFlagsFromDuration(this.metadata, this.duration); - } - } - - public setup(manager: ModerationManager) { - this.#manager = manager; - this.guildId = manager.guild.id; - return this; - } - - public clone() { - return new ModerationEntity(this).setup(this.#manager); - } - - public equals(other: ModerationEntity) { - return ( - this.type === other.type && - this.metadata === other.metadata && - this.duration === other.duration && - this.extraData === other.extraData && - this.reason === other.reason && - this.imageURL === other.imageURL && - this.userId === other.userId && - this.moderatorId === other.moderatorId - ); - } - - public get guild() { - return this.#manager.guild; - } - - public fetchChannel() { - return this.#manager.fetchChannel(); - } - - /** - * Retrieve the metadata (title and color) for this entry. - */ - public get meta(): ModerationTypeAssets { - const data = getMetadata(this.type, this.metadata); - if (typeof data === 'undefined') { - throw new Error(`Inexistent metadata for '0b${combineTypeData(this.type, this.metadata).toString(2).padStart(8, '0')}'.`); - } - return data; - } - - /** - * Retrieve the title for this entry's embed. - */ - public get title(): string { - return this.meta.title; - } - - /** - * Retrieve the color for this entry's embed. - */ - public get color(): number { - return this.meta.color; - } - - /** - * Retrieve the creation date for this entry's embed. Returns current date if not set. - */ - public get createdTimestamp(): number { - return this.createdAt?.getTime() ?? Date.now(); - } - - public get appealType() { - return (this.metadata & TypeMetadata.Appeal) === TypeMetadata.Appeal; - } - - public get temporaryType() { - return (this.metadata & TypeMetadata.Temporary) === TypeMetadata.Temporary; - } - - public get temporaryFastType() { - return (this.metadata & TypeMetadata.Fast) === TypeMetadata.Fast; - } - - public get invalidated() { - return (this.metadata & TypeMetadata.Invalidated) === TypeMetadata.Invalidated; - } - - public get appealable() { - return !this.appealType && hasMetadata(this.type, TypeMetadata.Appeal); - } - - public get temporable() { - return hasMetadata(this.type, TypeMetadata.Temporary); - } - - public get cacheExpired() { - return Date.now() > this.#timeout; - } - - public get cacheRemaining() { - return Math.max(Date.now() - this.#timeout, 0); - } - - public get appealTaskName() { - if (!this.appealable) return null; - switch (this.type) { - case TypeVariation.Warning: - return TypeVariationAppealNames.Warning; - case TypeVariation.Mute: - return TypeVariationAppealNames.Mute; - case TypeVariation.Ban: - return TypeVariationAppealNames.Ban; - case TypeVariation.VoiceMute: - return TypeVariationAppealNames.VoiceMute; - case TypeVariation.RestrictedAttachment: - return TypeVariationAppealNames.RestrictedAttachment; - case TypeVariation.RestrictedReaction: - return TypeVariationAppealNames.RestrictedReaction; - case TypeVariation.RestrictedEmbed: - return TypeVariationAppealNames.RestrictedEmbed; - case TypeVariation.RestrictedEmoji: - return TypeVariationAppealNames.RestrictedEmoji; - case TypeVariation.RestrictedVoice: - return TypeVariationAppealNames.RestrictedVoice; - case TypeVariation.SetNickname: - return TypeVariationAppealNames.SetNickname; - case TypeVariation.AddRole: - return TypeVariationAppealNames.AddRole; - case TypeVariation.RemoveRole: - return TypeVariationAppealNames.RemoveRole; - default: - return null; - } - } - - public get shouldSend() { - // If the moderation log is not anonymous, it should always send - if (this.moderatorId !== process.env.CLIENT_ID) return true; - - const before = Date.now() - Time.Minute; - const { type } = this; - const checkSoftBan = type === TypeVariation.Ban; - for (const entry of this.#manager.values()) { - // If it's not the same user target or if it's at least 1 minute old, skip - if (this.userId !== entry.userId || before > entry.createdTimestamp) continue; - - // If there was a log with the same type in the last minute, do not duplicate - if (type === entry.type) return false; - - // If this log is a ban or an unban, but the user was softbanned recently, abort - if (checkSoftBan && entry.type === TypeVariation.SoftBan) return false; - } - - // For all other cases, it should send - return true; - } - - public get task() { - const { guild } = this.#manager; - return ( - container.client.schedules.queue.find((value) => value.data && value.data.caseID === this.caseId && value.data.guildID === guild.id) ?? - null - ); - } - - public async fetchUser() { - if (!this.userId) { - throw new Error('userId must be set before calling this method.'); - } - - const previous = this.#user; - if (previous?.id === this.userId) return previous; - - const user = await container.client.users.fetch(this.userId); - this.#user = user; - return user; - } - - public async fetchModerator() { - const previous = this.#moderator; - if (previous) return previous; - - const moderator = await container.client.users.fetch(this.moderatorId); - this.#moderator = moderator; - return moderator; - } - - public async edit(data: ModerationManagerUpdateData = {}) { - const dataWithType = { ...data, metadata: ModerationEntity.getTypeFlagsFromDuration(this.metadata, data.duration ?? this.duration) }; - const clone = this.clone(); - try { - Object.assign(this, dataWithType); - await this.save(); - } catch (error) { - Object.assign(this, clone); - throw error; - } - - container.client.emit(Events.ModerationEntryEdit, clone, this); - return this; - } - - public async invalidate() { - if (this.invalidated) return this; - const clone = this.clone(); - try { - this.metadata |= TypeMetadata.Invalidated; - await this.save(); - } catch (error) { - this.metadata = clone.metadata; - throw error; - } - - container.client.emit(Events.ModerationEntryEdit, clone, this); - return this; - } - - public async prepareEmbed() { - if (!this.userId) throw new Error('A user has not been set.'); - const manager = this.#manager; - - const [user, moderator] = await Promise.all([this.fetchUser(), this.fetchModerator()]); - - const [prefix, t] = await readSettings(manager.guild, (settings) => [settings[GuildSettings.Prefix], settings.getLanguage()]); - const formattedDuration = this.duration ? t(LanguageKeys.Commands.Moderation.ModerationLogExpiresIn, { duration: this.duration }) : ''; - - const body = t(LanguageKeys.Commands.Moderation.ModerationLogDescriptionTypeAndUser, { - type: this.title, - userId: user.id, - userTag: getTag(user) - }); - const reason = t( - this.reason - ? LanguageKeys.Commands.Moderation.ModerationLogDescriptionWithReason - : LanguageKeys.Commands.Moderation.ModerationLogDescriptionWithoutReason, - { reason: this.reason, prefix, caseId: this.caseId, formattedDuration } - ); - - const embed = new EmbedBuilder() - .setColor(this.color) - .setAuthor(getFullEmbedAuthor(moderator)) - .setDescription(`${body}\n${reason}`) - .setFooter({ - text: t(LanguageKeys.Commands.Moderation.ModerationLogFooter, { caseId: this.caseId }), - iconURL: getDisplayAvatar(container.client.user!, { size: 128 }) - }) - .setTimestamp(this.createdTimestamp); - - if (this.imageURL) embed.setImage(this.imageURL); - return embed; - } - - public setCase(value: number) { - this.caseId = value; - return this; - } - - public setDuration(duration: string | number | null) { - if (this.temporable) { - if (typeof duration === 'string') duration = new Duration(duration.trim()).offset; - if (typeof duration === 'number' && (duration <= 0 || duration > Time.Year)) duration = null; - - if (isNumber(duration)) { - if (duration < 0 || duration > years(5)) { - throw new UserError({ - identifier: LanguageKeys.Commands.Moderation.AutomaticParameterShowDurationPermanent, - context: { duration } - }); - } - this.duration = isNullishOrZero(duration) ? null : duration; - } else { - this.duration = null; - } - } else { - this.duration = null; - } - - this.metadata = ModerationEntity.getTypeFlagsFromDuration(this.metadata, this.duration); - return this; - } - - public setExtraData(value: Record | null) { - this.extraData = value; - return this; - } - - public setModerator(value: User | string) { - if (value instanceof User) { - this.#moderator = value; - this.moderatorId = value.id; - } else if (this.moderatorId !== value) { - this.#moderator = null; - this.moderatorId = value; - } - return this; - } - - public setReason(value?: string | null) { - if (typeof value === 'string') { - const trimmed = value.trim(); - value = trimmed.length === 0 ? null : trimmed; - } else { - value = null; - } - - this.reason = value; - return this; - } - - public setImageURL(value?: string | null) { - this.imageURL = (value && tryParseURL(value)?.href) ?? null; - return this; - } - - public setUser(value: User | string) { - if (value instanceof User) { - this.#user = value; - this.userId = value.id; - } else { - this.userId = value; - } - - return this; - } - - public async create() { - // If the entry was created, there is no point on re-sending - if (!this.userId || this.createdAt) return null; - this.createdAt = new Date(); - - // If the entry should not send, abort creation - if (!this.shouldSend) return null; - - await this.#manager.save(this); - - container.client.emit(Events.ModerationEntryAdd, this); - return this; - } - - private static getTypeFlagsFromDuration(metadata: TypeMetadata, duration: number | null) { - if (duration === null) return metadata & ~(TypeMetadata.Temporary | TypeMetadata.Fast); - if (duration < Time.Minute) return metadata | TypeMetadata.Temporary | TypeMetadata.Fast; - return metadata | TypeMetadata.Temporary; - } } diff --git a/src/lib/database/entities/ScheduleEntity.ts b/src/lib/database/entities/ScheduleEntity.ts index e01320fb641..706fb27dc4d 100644 --- a/src/lib/database/entities/ScheduleEntity.ts +++ b/src/lib/database/entities/ScheduleEntity.ts @@ -121,6 +121,10 @@ export class ScheduleEntity extends BaseEntity { return this; } + public reschedule(time: Date | number) { + return this.#manager.reschedule(this, time); + } + public delete() { return this.#manager.remove(this); } diff --git a/src/lib/database/utils/DbSet.ts b/src/lib/database/utils/DbSet.ts index 8bf1fdd9a7c..515c8e6da8f 100644 --- a/src/lib/database/utils/DbSet.ts +++ b/src/lib/database/utils/DbSet.ts @@ -6,7 +6,7 @@ import { ModerationEntity } from '#lib/database/entities/ModerationEntity'; import { ScheduleEntity } from '#lib/database/entities/ScheduleEntity'; import { TwitchSubscriptionEntity } from '#lib/database/entities/TwitchSubscriptionEntity'; import { UserEntity } from '#lib/database/entities/UserEntity'; -import type { DataSource, FindManyOptions, FindOptions, Repository } from 'typeorm'; +import type { DataSource, Repository } from 'typeorm'; export class DbSet { public readonly connection: DataSource; @@ -34,34 +34,6 @@ export class DbSet { return entry?.moderationDM ?? true; } - /** - * Finds entities that match given options. - */ - public fetchModerationEntry(options?: FindManyOptions): Promise; - - /** - * Finds entities that match given conditions. - */ - // eslint-disable-next-line @typescript-eslint/unified-signatures - public fetchModerationEntry(conditions?: FindOptions): Promise; - public async fetchModerationEntry(optionsOrConditions?: FindOptions | FindManyOptions) { - return this.moderations.findOne(optionsOrConditions as any); - } - - /** - * Finds entities that match given options. - */ - public fetchModerationEntries(options?: FindManyOptions): Promise; - - /** - * Finds entities that match given conditions. - */ - // eslint-disable-next-line @typescript-eslint/unified-signatures - public fetchModerationEntries(conditions?: FindOptions): Promise; - public async fetchModerationEntries(optionsOrConditions?: FindOptions | FindManyOptions) { - return this.moderations.find(optionsOrConditions as any); - } - public static instance: DbSet | null = null; private static connectPromise: Promise | null; diff --git a/src/lib/i18n/languageKeys/keys/All.ts b/src/lib/i18n/languageKeys/keys/All.ts index 19f93ba1bc2..9050553d8fd 100644 --- a/src/lib/i18n/languageKeys/keys/All.ts +++ b/src/lib/i18n/languageKeys/keys/All.ts @@ -1,12 +1,14 @@ export * as Arguments from '#lib/i18n/languageKeys/keys/Arguments'; export * as Assertions from '#lib/i18n/languageKeys/keys/Assertions'; export * as Commands from '#lib/i18n/languageKeys/keys/Commands'; -export * as Events from '#lib/i18n/languageKeys/keys/events/All'; +export * as Errors from '#lib/i18n/languageKeys/keys/Errors'; export * as FuzzySearch from '#lib/i18n/languageKeys/keys/FuzzySearch'; export * as Globals from '#lib/i18n/languageKeys/keys/Globals'; export * as Guilds from '#lib/i18n/languageKeys/keys/Guilds'; export * as Misc from '#lib/i18n/languageKeys/keys/Misc'; +export * as Moderation from '#lib/i18n/languageKeys/keys/Moderation'; export * as Preconditions from '#lib/i18n/languageKeys/keys/Preconditions'; export * as Serializers from '#lib/i18n/languageKeys/keys/Serializers'; export * as Settings from '#lib/i18n/languageKeys/keys/Settings'; export * as System from '#lib/i18n/languageKeys/keys/System'; +export * as Events from '#lib/i18n/languageKeys/keys/events/All'; diff --git a/src/lib/i18n/languageKeys/keys/Arguments.ts b/src/lib/i18n/languageKeys/keys/Arguments.ts index 4d4099fa3c9..aab263b2280 100644 --- a/src/lib/i18n/languageKeys/keys/Arguments.ts +++ b/src/lib/i18n/languageKeys/keys/Arguments.ts @@ -5,6 +5,9 @@ export const BooleanError = FT<{ parameter: string; possibles: string[]; count: export const BooleanEnabled = T('arguments:booleanEnabled'); export const BooleanFalseOptions = T('arguments:booleanFalseOptions'); export const BooleanTrueOptions = T('arguments:booleanTrueOptions'); +export const CaseNoEntries = FT<{ parameter: string }>('arguments:caseNoEntries'); +export const CaseUnknownEntry = FT<{ parameter: string }>('arguments:caseUnknownEntry'); +export const CaseNotInThisGuild = FT<{ parameter: string }>('arguments:caseNotInThisGuild'); export const CaseLatestOptions = T('arguments:caseLatestOptions'); export const CategoryChannelError = FT<{ parameter: string }>('arguments:categoryChannelError'); export const ChannelError = FT<{ parameter: string }>('arguments:channelError'); diff --git a/src/lib/i18n/languageKeys/keys/Commands.ts b/src/lib/i18n/languageKeys/keys/Commands.ts index 5ddfbcc00b2..105b50aafe3 100644 --- a/src/lib/i18n/languageKeys/keys/Commands.ts +++ b/src/lib/i18n/languageKeys/keys/Commands.ts @@ -1,4 +1,5 @@ export * as Admin from '#lib/i18n/languageKeys/keys/commands/Admin'; +export * as Case from '#lib/i18n/languageKeys/keys/commands/Case'; export * as Fun from '#lib/i18n/languageKeys/keys/commands/Fun'; export * as Games from '#lib/i18n/languageKeys/keys/commands/Games'; export * as General from '#lib/i18n/languageKeys/keys/commands/General'; diff --git a/src/lib/i18n/languageKeys/keys/Errors.ts b/src/lib/i18n/languageKeys/keys/Errors.ts new file mode 100644 index 00000000000..6bc55f12a83 --- /dev/null +++ b/src/lib/i18n/languageKeys/keys/Errors.ts @@ -0,0 +1,11 @@ +import { T } from '#lib/types'; + +export const GenericUnknownChannel = T('errors:genericUnknownChannel'); +export const GenericUnknownGuild = T('errors:genericUnknownGuild'); +export const GenericUnknownMember = T('errors:genericUnknownMember'); +export const GenericUnknownMessage = T('errors:genericUnknownMessage'); +export const GenericUnknownRole = T('errors:genericUnknownRole'); +export const GenericMissingAccess = T('errors:genericMissingAccess'); +export const GenericDiscordInternalServerError = T('errors:genericDiscordInternalServerError'); +export const GenericDiscordGateway = T('errors:genericDiscordGateway'); +export const GenericDiscordUnavailable = T('errors:genericDiscordUnavailable'); diff --git a/src/lib/i18n/languageKeys/keys/Moderation.ts b/src/lib/i18n/languageKeys/keys/Moderation.ts new file mode 100644 index 00000000000..be7c2384537 --- /dev/null +++ b/src/lib/i18n/languageKeys/keys/Moderation.ts @@ -0,0 +1,45 @@ +import { FT, T } from '#lib/types'; + +export const TypeBan = T('moderation:typeBan'); +export const TypeKick = T('moderation:typeKick'); +export const TypeMute = T('moderation:typeMute'); +export const TypeRestrictedAttachment = T('moderation:typeRestrictedAttachment'); +export const TypeRestrictedEmbed = T('moderation:typeRestrictedEmbed'); +export const TypeRestrictedEmoji = T('moderation:typeRestrictedEmoji'); +export const TypeRestrictedReaction = T('moderation:typeRestrictedReaction'); +export const TypeRestrictedVoice = T('moderation:typeRestrictedVoice'); +export const TypeRoleAdd = T('moderation:typeRoleAdd'); +export const TypeRoleRemove = T('moderation:typeRoleRemove'); +export const TypeSetNickname = T('moderation:typeSetNickname'); +export const TypeSoftban = T('moderation:typeSoftban'); +export const TypeTimeout = T('moderation:typeTimeout'); +export const TypeVoiceKick = T('moderation:typeVoiceKick'); +export const TypeVoiceMute = T('moderation:typeVoiceMute'); +export const TypeWarning = T('moderation:typeWarning'); + +export const MetadataUndo = FT<{ name: string }>('moderation:metadataUndo'); +export const MetadataTemporary = FT<{ name: string }>('moderation:metadataTemporary'); + +export const EmbedUser = FT<{ tag: string; id: string }>('moderation:embedUser'); +export const EmbedDescription = FT<{ type: string; user: string; reason: string }>('moderation:embedDescription'); +export const EmbedDescriptionTemporary = FT<{ type: string; user: string; time: string; reason: string }>('moderation:embedDescriptionTemporary'); +export const EmbedReasonNotSet = FT<{ command: string; caseId: number }>('moderation:embedReasonNotSet'); +export const EmbedFooter = FT<{ caseId: number }>('moderation:embedFooter'); + +// Action status +export const ActionIsActive = T('moderation:actionIsActive'); +export const ActionIsNotActive = T('moderation:actionIsNotActive'); + +// Action status overrides +export const ActionIsActiveRole = T('moderation:actionIsActiveRole'); +export const ActionIsNotActiveRole = T('moderation:actionIsNotActiveRole'); +export const ActionIsActiveRestrictionRole = T('moderation:actionIsActiveRestrictionRole'); +export const ActionIsNotActiveRestrictionRole = T('moderation:actionIsNotActiveRestrictionRole'); +export const ActionIsActiveNickname = T('moderation:actionIsActiveNickname'); +export const ActionIsNotActiveNickname = T('moderation:actionIsNotActiveNickname'); + +export const ActionTargetSelf = T('moderation:actionTargetSelf'); +export const ActionTargetGuildOwner = T('moderation:actionTargetGuildOwner'); +export const ActionTargetSkyra = T('moderation:actionTargetSkyra'); +export const ActionTargetHigherHierarchySkyra = T('moderation:actionTargetHigherHierarchySkyra'); +export const ActionTargetHigherHierarchyAuthor = T('moderation:actionTargetHigherHierarchyAuthor'); diff --git a/src/lib/i18n/languageKeys/keys/commands/Case.ts b/src/lib/i18n/languageKeys/keys/commands/Case.ts new file mode 100644 index 00000000000..c87b6d84a59 --- /dev/null +++ b/src/lib/i18n/languageKeys/keys/commands/Case.ts @@ -0,0 +1,47 @@ +import { FT, T } from '#lib/types'; + +// Root +export const Name = T('commands/case:name'); +export const Description = T('commands/case:description'); + +export const View = 'commands/case:view'; +export const Archive = 'commands/case:archive'; +export const Delete = 'commands/case:delete'; +export const Edit = 'commands/case:edit'; +export const List = 'commands/case:list'; + +export const OptionsCase = 'commands/case:optionsCase'; +export const OptionsReason = 'commands/case:optionsReason'; +export const OptionsDuration = 'commands/case:optionsDuration'; +export const OptionsUser = 'commands/case:optionsUser'; +export const OptionsOverview = 'commands/case:optionsOverview'; +export const OptionsType = 'commands/case:optionsType'; +export const OptionsPendingOnly = 'commands/case:optionsPendingOnly'; +export const OptionsShow = 'commands/case:optionsShow'; + +export const TimeNotAllowed = FT<{ type: string }>('commands/case:timeNotAllowed'); +export const TimeNotAllowedInCompletedEntries = FT<{ caseId: number }>('commands/case:timeNotAllowedInCompletedEntries'); +export const TimeTooEarly = FT<{ time: string }>('commands/case:timeTooEarly'); +export const ListEmpty = T('commands/case:listEmpty'); +export const ListDetailsTitle = FT<{ count: number }>('commands/case:listDetailsTitle'); +export const ListDetailsModerator = FT<{ emoji: string; mention: string; userId: string }>('commands/case:listDetailsModerator'); +export const ListDetailsUser = FT<{ emoji: string; mention: string; userId: string }>('commands/case:listDetailsUser'); +export const ListDetailsExpires = FT<{ emoji: string; time: string }>('commands/case:listDetailsExpires'); +export const ListOverviewFooter = FT('commands/case:listOverviewFooter'); +export const ListOverviewFooterUser = FT('commands/case:listOverviewFooterUser'); +export const ListOverviewFooterWarning = FT<{ count: number }>('commands/case:listOverviewFooterWarning'); +export const ListOverviewFooterMutes = FT<{ count: number }>('commands/case:listOverviewFooterMutes'); +export const ListOverviewFooterTimeouts = FT<{ count: number }>('commands/case:listOverviewFooterTimeouts'); +export const ListOverviewFooterKicks = FT<{ count: number }>('commands/case:listOverviewFooterKicks'); +export const ListOverviewFooterBans = FT<{ count: number }>('commands/case:listOverviewFooterBans'); +export const EditSuccess = FT<{ caseId: number }>('commands/case:editSuccess'); +export const ArchiveSuccess = FT<{ caseId: number }>('commands/case:archiveSuccess'); +export const DeleteSuccess = FT<{ caseId: number }>('commands/case:deleteSuccess'); + +interface ListOverview { + warnings: string; + mutes: string; + timeouts: string; + kicks: string; + bans: string; +} diff --git a/src/lib/i18n/languageKeys/keys/commands/Moderation.ts b/src/lib/i18n/languageKeys/keys/commands/Moderation.ts index a0c12d03b78..009c9a85909 100644 --- a/src/lib/i18n/languageKeys/keys/commands/Moderation.ts +++ b/src/lib/i18n/languageKeys/keys/commands/Moderation.ts @@ -1,61 +1,12 @@ import type { LanguageHelpDisplayOptions } from '#lib/i18n/LanguageHelp'; import { FT, T } from '#lib/types'; -import type { ModerationManagerDescriptionData } from '#utils/moderationConstants'; -export interface ModerationAction { - addRole: string; - mute: string; - ban: string; - kick: string; - softban: string; - vkick: string; - vmute: string; - restrictedReact: string; - restrictedEmbed: string; - restrictedAttachment: string; - restrictedVoice: string; - setNickname: string; - removeRole: string; -} - -export const HistoryDescription = T('commands/moderation:historyDescription'); -export const HistoryExtended = T('commands/moderation:historyExtended'); -export const HistoryFooterNew = FT< - { - warnings: number; - mutes: number; - kicks: number; - bans: number; - warningsText: string; - mutesText: string; - kicksText: string; - bansText: string; - }, - string ->('commands/moderation:historyFooterNew'); -export const HistoryFooterWarning = FT<{ count: number }, string>('commands/moderation:historyFooterWarning'); -export const HistoryFooterMutes = FT<{ count: number }, string>('commands/moderation:historyFooterMutes'); -export const HistoryFooterKicks = FT<{ count: number }, string>('commands/moderation:historyFooterKicks'); -export const HistoryFooterBans = FT<{ count: number }, string>('commands/moderation:historyFooterBans'); -export const ModerationsDescription = T('commands/moderation:moderationsDescription'); -export const ModerationsExtended = T('commands/moderation:moderationsExtended'); -export const ModerationsEmpty = FT<{ prefix: string }, string>('commands/moderation:moderationsEmpty'); -export const ModerationsAmount = FT<{ count: number }, string>('commands/moderation:moderationsAmount'); -export const MutesDescription = T('commands/moderation:mutesDescription'); -export const MutesExtended = T('commands/moderation:mutesExtended'); -export const WarningsDescription = T('commands/moderation:warningsDescription'); -export const WarningsExtended = T('commands/moderation:warningsExtended'); export const MuteDescription = T('commands/moderation:muteDescription'); export const MuteExtended = T('commands/moderation:muteExtended'); export const PruneDescription = T('commands/moderation:pruneDescription'); export const PruneExtended = T('commands/moderation:pruneExtended'); -export const CaseDescription = T('commands/moderation:caseDescription'); -export const CaseExtended = T('commands/moderation:caseExtended'); -export const CaseDeleted = FT<{ case: number }, string>('commands/moderation:caseDeleted'); export const PermissionsDescription = T('commands/moderation:permissionsDescription'); export const PermissionsExtended = T('commands/moderation:permissionsExtended'); -export const ReasonDescription = T('commands/moderation:reasonDescription'); -export const ReasonExtended = T('commands/moderation:reasonExtended'); export const RestrictAttachmentDescription = T('commands/moderation:restrictAttachmentDescription'); export const RestrictAttachmentExtended = T('commands/moderation:restrictAttachmentExtended'); export const RestrictEmbedDescription = T('commands/moderation:restrictEmbedDescription'); @@ -84,8 +35,6 @@ export const UnrestrictReactionDescription = T('commands/moderation:unrestrictRe export const UnrestrictReactionExtended = T('commands/moderation:unrestrictReactionExtended'); export const UnrestrictVoiceDescription = T('commands/moderation:unrestrictVoiceDescription'); export const UnrestrictVoiceExtended = T('commands/moderation:unrestrictVoiceExtended'); -export const UnwarnDescription = T('commands/moderation:unwarnDescription'); -export const UnwarnExtended = T('commands/moderation:unwarnExtended'); export const VmuteDescription = T('commands/moderation:vmuteDescription'); export const VmuteExtended = T('commands/moderation:vmuteExtended'); export const VoiceKickDescription = T('commands/moderation:voiceKickDescription'); @@ -94,18 +43,11 @@ export const VunmuteDescription = T('commands/moderation:vunmuteDescription'); export const VunmuteExtended = T('commands/moderation:vunmuteExtended'); export const WarnDescription = T('commands/moderation:warnDescription'); export const WarnExtended = T('commands/moderation:warnExtended'); -export const TimeTimed = T('commands/moderation:timeTimed'); -export const TimeUnsupportedType = T('commands/moderation:timeUnsupportedType'); -export const TimeNotScheduled = T('commands/moderation:timeNotScheduled'); -export const TimeAborted = FT<{ title: string }, string>('commands/moderation:timeAborted'); -export const TimeScheduled = FT<{ title: string; userId: string; userTag: string; time: number }, string>('commands/moderation:timeScheduled'); -export const SlowmodeSet = FT<{ cooldown: number }, string>('commands/moderation:slowmodeSet'); +export const SlowmodeSet = FT<{ cooldown: number }>('commands/moderation:slowmodeSet'); export const SlowmodeReset = T('commands/moderation:slowmodeReset'); -export const TimeDescription = T('commands/moderation:timeDescription'); -export const TimeExtended = T('commands/moderation:timeExtended'); export const BanNotBannable = T('commands/moderation:banNotBannable'); -export const DehoistStarting = FT<{ count: number }, string>('commands/moderation:dehoistStarting'); -export const DehoistProgress = FT<{ count: number; percentage: number }, string>('commands/moderation:dehoistProgress'); +export const DehoistStarting = FT<{ count: number }>('commands/moderation:dehoistStarting'); +export const DehoistProgress = FT<{ count: number; percentage: number }>('commands/moderation:dehoistProgress'); export const DehoistEmbed = FT< { users: number; dehoistedMemberCount: number; dehoistedWithErrorsCount: number; errored: number }, { @@ -119,65 +61,45 @@ export const DehoistEmbed = FT< } >('commands/moderation:dehoistEmbed'); export const KickNotKickable = T('commands/moderation:kickNotKickable'); -export const LockdownLock = FT<{ channel: string }, string>('commands/moderation:lockdownLock'); -export const LockdownLocking = FT<{ channel: string }, string>('commands/moderation:lockdownLocking'); -export const LockdownLocked = FT<{ channel: string }, string>('commands/moderation:lockdownLocked'); -export const LockdownUnlocked = FT<{ channel: string }, string>('commands/moderation:lockdownUnlocked'); -export const LockdownOpen = FT<{ channel: string }, string>('commands/moderation:lockdownOpen'); +export const LockdownLock = FT<{ channel: string }>('commands/moderation:lockdownLock'); +export const LockdownLocking = FT<{ channel: string }>('commands/moderation:lockdownLocking'); +export const LockdownLocked = FT<{ channel: string }>('commands/moderation:lockdownLocked'); +export const LockdownUnlocked = FT<{ channel: string }>('commands/moderation:lockdownUnlocked'); +export const LockdownOpen = FT<{ channel: string }>('commands/moderation:lockdownOpen'); export const MuteMuted = T('commands/moderation:muteMuted'); -export const MuteUserNotMuted = T('commands/moderation:muteUserNotMuted'); export const RestrictLowlevel = T('commands/moderation:restrictLowlevel'); -export const PruneAlert = FT<{ count: number; total: number }, string>('commands/moderation:pruneAlert'); +export const PruneAlert = FT<{ count: number; total: number }>('commands/moderation:pruneAlert'); export const PruneInvalidPosition = T('commands/moderation:pruneInvalidPosition'); export const PruneNoDeletes = T('commands/moderation:pruneNoDeletes'); export const PruneLogHeader = T('commands/moderation:pruneLogHeader'); -export const PruneLogMessage = FT<{ channel: string; author: string; count: number }, string>('commands/moderation:pruneLogMessage'); -export const ReasonNotExists = T('commands/moderation:reasonNotExists'); -export const ReasonUpdated = FT<{ entries: readonly number[]; newReason: string; count: number }>('commands/moderation:reasonUpdated'); export const ToggleModerationDmToggledEnabled = T('commands/moderation:toggleModerationDmToggledEnabled'); export const ToggleModerationDmToggledDisabled = T('commands/moderation:toggleModerationDmToggledDisabled'); -export const UnbanMissingPermission = T('commands/moderation:unbanMissingPermission'); -export const UnmuteMissingPermission = T('commands/moderation:unmuteMissingPermission'); -export const VmuteMissingPermission = T('commands/moderation:vmuteMissingPermission'); export const VmuteUserNotMuted = T('commands/moderation:vmuteUserNotMuted'); -export const ModerationOutput = FT<{ count: number; range: string | number; users: string; reason: string | null }, string>( +export const ModerationOutput = FT<{ count: number; range: string | number; users: string; reason: string | null }>( 'commands/moderation:moderationOutput' ); -export const ModerationOutputWithReason = FT<{ count: number; range: string | number; users: string; reason: string | null }, string>( +export const ModerationOutputWithReason = FT<{ count: number; range: string | number; users: string; reason: string | null }>( 'commands/moderation:moderationOutputWithReason' ); -export const ModerationCaseNotExists = FT<{ count: number }, string>('moderation:caseNotExists'); -export const ModerationLogAppealed = T('moderation:logAppealed'); -export const ModerationLogDescriptionTypeAndUser = FT<{ type: string; userId: string; userTag: string }, string>( - 'moderation:logDescriptionTypeAndUser' -); -export const ModerationLogDescriptionWithReason = FT, string>( - 'moderation:logDescriptionWithReason' -); -export const ModerationLogDescriptionWithoutReason = FT('moderation:logDescriptionWithoutReason'); export const GuildBansEmpty = T('errors:guildBansEmpty'); export const GuildBansNotFound = T('errors:guildBansNotFound'); export const GuildMemberNotVoicechannel = T('errors:guildMemberNotVoicechannel'); -export const GuildWarnNotFound = T('errors:guildWarnNotFound'); -export const ModerationLogExpiresIn = FT<{ duration: number }, string>('moderation:logExpiresIn'); -export const ModerationLogFooter = FT<{ caseId: number }, string>('moderation:logFooter'); -export const ModerationTimed = FT<{ remaining: number }, string>('errors:modlogTimed'); -export const ModerationFailed = FT<{ users: string; count: number }, string>('commands/moderation:moderationFailed'); +export const ModerationFailed = FT<{ users: string; count: number }>('commands/moderation:moderationFailed'); export const ModerationDmFooter = T('commands/moderation:moderationDmFooter'); -export const ModerationDmDescription = FT<{ guild: string; title: string; reason: string | null; duration: number | null }, string>( +export const ModerationDmDescription = FT<{ guild: string; title: string; reason: string | null; duration: number | null }>( 'commands/moderation:moderationDmDescription' ); -export const ModerationDmDescriptionWithReason = FT<{ guild: string; title: string; reason: string | null; duration: number | null }, string>( +export const ModerationDmDescriptionWithReason = FT<{ guild: string; title: string; reason: string | null; duration: number | null }>( 'commands/moderation:moderationDmDescriptionWithReason' ); -export const ModerationDmDescriptionWithDuration = FT<{ guild: string; title: string; reason: string | null; duration: number | null }, string>( +export const ModerationDmDescriptionWithDuration = FT<{ guild: string; title: string; reason: string | null; duration: number | null }>( 'commands/moderation:moderationDmDescriptionWithDuration' ); export const ModerationDmDescriptionWithReasonWithDuration = FT< { guild: string; title: string; reason: string | null; duration: number | null }, string >('commands/moderation:moderationDmDescriptionWithReasonWithDuration'); -export const Permissions = FT<{ username: string; id: string }, string>('commands/moderation:permissions'); +export const Permissions = FT<{ username: string; id: string }>('commands/moderation:permissions'); export const PermissionsAll = T('commands/moderation:permissionsAll'); export const SlowmodeDescription = T('commands/moderation:slowmodeDescription'); export const SlowmodeExtended = T('commands/moderation:slowmodeExtended'); @@ -195,39 +117,31 @@ export const KickDescription = T('commands/moderation:kickDescription'); export const KickExtended = T('commands/moderation:kickExtended'); export const LockdownDescription = T('commands/moderation:lockdownDescription'); export const LockdownExtended = T('commands/moderation:lockdownExtended'); -export const MuteCannotManageRoles = T('moderation:muteCannotManageRoles'); -export const MuteLowHierarchy = T('moderation:muteLowHierarchy'); -export const MuteNotConfigured = T('moderation:muteNotConfigured'); -export const MuteNotExists = T('moderation:muteNotExists'); -export const MuteNotInMember = T('moderation:muteNotInMember'); -export const AutomaticParameterInvalidMissingAction = FT<{ name: string }, string>('selfModeration:commandInvalidMissingAction'); -export const AutomaticParameterInvalidMissingArguments = FT<{ name: string }, string>('selfModeration:commandInvalidMissingArguments'); -export const AutomaticParameterInvalidSoftAction = FT<{ name: string }, string>('selfModeration:commandInvalidSoftaction'); -export const AutomaticParameterInvalidHardAction = FT<{ name: string }, string>('selfModeration:commandInvalidHardaction'); +export const AutomaticParameterInvalidMissingAction = FT<{ name: string }>('selfModeration:commandInvalidMissingAction'); +export const AutomaticParameterInvalidMissingArguments = FT<{ name: string }>('selfModeration:commandInvalidMissingArguments'); +export const AutomaticParameterInvalidSoftAction = FT<{ name: string }>('selfModeration:commandInvalidSoftaction'); +export const AutomaticParameterInvalidHardAction = FT<{ name: string }>('selfModeration:commandInvalidHardaction'); export const AutomaticParameterEnabled = T('selfModeration:commandEnabled'); export const AutomaticParameterDisabled = T('selfModeration:commandDisabled'); export const AutomaticParameterSoftAction = T('selfModeration:commandSoftAction'); -export const AutomaticParameterSoftActionWithValue = FT<{ value: string }, string>('selfModeration:commandSoftActionWithValue'); -export const AutomaticParameterHardAction = FT<{ value: string }, string>('selfModeration:commandHardAction'); +export const AutomaticParameterSoftActionWithValue = FT<{ value: string }>('selfModeration:commandSoftActionWithValue'); +export const AutomaticParameterHardAction = FT<{ value: string }>('selfModeration:commandHardAction'); export const AutomaticParameterHardActionDuration = T('selfModeration:commandHardActionDuration'); -export const AutomaticParameterHardActionDurationWithValue = FT<{ value: number }, string>('selfModeration:commandHardActionDurationWithValue'); +export const AutomaticParameterHardActionDurationWithValue = FT<{ value: number }>('selfModeration:commandHardActionDurationWithValue'); export const AutomaticParameterThresholdMaximum = T('selfModeration:commandThresholdMaximum'); -export const AutomaticParameterThresholdMaximumWithValue = FT<{ value: number }, string>('selfModeration:commandThresholdMaximumWithValue'); +export const AutomaticParameterThresholdMaximumWithValue = FT<{ value: number }>('selfModeration:commandThresholdMaximumWithValue'); export const AutomaticParameterThresholdDuration = T('selfModeration:commandThresholdDuration'); -export const AutomaticParameterThresholdDurationWithValue = FT<{ value: number }, string>('selfModeration:commandThresholdDurationWithValue'); -export const AutomaticParameterShow = FT< - { - kEnabled: string; - kAlert: string; - kLog: string; - kDelete: string; - kHardAction: string; - hardActionDurationText: string; - thresholdMaximumText: string | number; - thresholdDurationText: string; - }, - string ->('selfModeration:commandShow'); +export const AutomaticParameterThresholdDurationWithValue = FT<{ value: number }>('selfModeration:commandThresholdDurationWithValue'); +export const AutomaticParameterShow = FT<{ + kEnabled: string; + kAlert: string; + kLog: string; + kDelete: string; + kHardAction: string; + hardActionDurationText: string; + thresholdMaximumText: string | number; + thresholdDurationText: string; +}>('selfModeration:commandShow'); export const AutomaticParameterShowDurationPermanent = T('selfModeration:commandShowDurationPermanent'); export const AutomaticParameterShowUnset = T('selfModeration:commandShowUnset'); export const AutomaticValueSoftActionAlert = T('selfModeration:softActionAlert'); @@ -239,30 +153,28 @@ export const AutomaticValueHardActionMute = T('selfModeration:hardActionMute'); export const AutomaticValueHardActionSoftBan = T('selfModeration:hardActionSoftban'); export const AutomaticValueHardActionWarning = T('selfModeration:hardActionWarning'); export const AutomaticValueHardActionNone = T('selfModeration:hardActionNone'); -export const Actions = T('moderationActions:actions'); -export const ActionApplyReason = FT<{ action: string; reason: string }, string>('moderationActions:applyReason'); -export const ActionApplyNoReason = FT<{ action: string }, string>('moderationActions:applyNoReason'); -export const ActionRevokeReason = FT<{ action: string; reason: string }, string>('moderationActions:revokeReason'); -export const ActionRevokeNoReason = FT<{ action: string }, string>('moderationActions:revokeNoReason'); -export const ActionSoftBanReason = FT<{ reason: string }, string>('moderationActions:softbanReason'); -export const ActionUnSoftBanReason = FT<{ reason: string }, string>('moderationActions:unSoftbanReason'); +export const ActionApplyReason = FT<{ action: string; reason: string }>('moderationActions:applyReason'); +export const ActionApplyNoReason = FT<{ action: string }>('moderationActions:applyNoReason'); +export const ActionRevokeReason = FT<{ action: string; reason: string }>('moderationActions:revokeReason'); +export const ActionRevokeNoReason = FT<{ action: string }>('moderationActions:revokeNoReason'); +export const ActionSoftBanReason = FT<{ reason: string }>('moderationActions:softbanReason'); +export const ActionUnSoftBanReason = FT<{ reason: string }>('moderationActions:unSoftbanReason'); export const ActionSoftBanNoReason = T('moderationActions:softbanNoReason'); export const ActionUnSoftBanNoReason = T('moderationActions:unSoftbanNoReason'); -export const ActionSetNicknameSet = FT<{ reason: string }, string>('moderationActions:setNicknameSet'); -export const ActionSetNicknameRemoved = FT<{ reason: string }, string>('moderationActions:setNicknameRemoved'); +export const ActionSetNicknameSet = FT<{ reason: string }>('moderationActions:setNicknameSet'); +export const ActionSetNicknameRemoved = FT<{ reason: string }>('moderationActions:setNicknameRemoved'); export const ActionSetNicknameNoReasonSet = T('moderationActions:setNicknameNoReasonSet'); export const ActionSetNicknameNoReasonRemoved = T('moderationActions:setNicknameNoReasonRemoved'); export const ActionSetupMuteExists = T('moderationActions:setupMuteExists'); -export const ActionSetupRestrictionExists = T('moderationActions:setupRestrictionExists'); export const ActionSetupTooManyRoles = T('moderationActions:setupTooManyRoles'); export const ActionSharedRoleSetupExisting = T('moderationActions:sharedRoleSetupExisting'); export const ActionSharedRoleSetupExistingName = T('moderationActions:sharedRoleSetupExistingName'); export const ActionSharedRoleSetupNew = T('moderationActions:sharedRoleSetupNew'); -export const ActionSharedRoleSetupAsk = FT<{ role: string; channels: number; permissions: string }, string>('moderationActions:sharedRoleSetupAsk'); +export const ActionSharedRoleSetupAsk = FT<{ role: string; channels: number; permissions: string }>('moderationActions:sharedRoleSetupAsk'); export const ActionSharedRoleSetupNoMessage = T('moderationActions:sharedRoleSetupNoMessage'); export const ActionRequiredMember = T('moderationActions:requiredMember'); -export const RoleHigher = T('moderation:roleHigher'); -export const RoleHigherSkyra = T('moderation:roleHigherSkyra'); +export const ActionCannotManageRoles = T('moderationActions:actionCannotManageRoles'); +export const ActionRoleNotConfigured = T('moderationActions:actionRoleNotConfigured'); +export const ActionRoleHigherPosition = T('moderationActions:actionRoleHigherPosition'); +export const ActionRoleManaged = T('moderationActions:actionRoleManaged'); export const Success = T('moderation:success'); -export const ToSkyra = T('moderation:toSkyra'); -export const UserSelf = T('moderation:userSelf'); diff --git a/src/lib/i18n/languageKeys/keys/commands/Shared.ts b/src/lib/i18n/languageKeys/keys/commands/Shared.ts index d4e7ca56af2..7f44461099c 100644 --- a/src/lib/i18n/languageKeys/keys/commands/Shared.ts +++ b/src/lib/i18n/languageKeys/keys/commands/Shared.ts @@ -1,3 +1,5 @@ -import { FT } from '#lib/types'; +import type { LanguageHelpDisplayOptions } from '#lib/i18n/LanguageHelp'; +import { FT, T } from '#lib/types'; export const DeprecatedMessage = FT<{ command: string }>('commands/shared:deprecatedMessage'); +export const SlashDetailed = T('commands/shared:slashDetailed'); diff --git a/src/lib/i18n/translate.ts b/src/lib/i18n/translate.ts index cd52b2b02d0..c13261593bf 100644 --- a/src/lib/i18n/translate.ts +++ b/src/lib/i18n/translate.ts @@ -124,12 +124,20 @@ export function getT(locale?: LocaleString | Nullish) { return container.i18n.getT(locale ?? 'en-US'); } -export function getSupportedUserLanguageName(interaction: Interaction): LocaleString { - if (container.i18n.languages.has(interaction.locale)) return interaction.locale; +export function getSupportedLanguageName(interaction: Interaction): LocaleString { if (interaction.guildLocale && container.i18n.languages.has(interaction.guildLocale)) return interaction.guildLocale; return 'en-US'; } +export function getSupportedLanguageT(interaction: Interaction): TFunction { + return getT(getSupportedLanguageName(interaction)); +} + +export function getSupportedUserLanguageName(interaction: Interaction): LocaleString { + if (container.i18n.languages.has(interaction.locale)) return interaction.locale; + return getSupportedLanguageName(interaction); +} + export function getSupportedUserLanguageT(interaction: Interaction): TFunction { return getT(getSupportedUserLanguageName(interaction)); } diff --git a/src/lib/moderation/actions/ModerationActionBan.ts b/src/lib/moderation/actions/ModerationActionBan.ts new file mode 100644 index 00000000000..af7d1bc9088 --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionBan.ts @@ -0,0 +1,35 @@ +import { api } from '#lib/discord/Api'; +import { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; +import { resolveOnErrorCodes } from '#utils/common'; +import { TypeVariation } from '#utils/moderationConstants'; +import { isNullish } from '@sapphire/utilities'; +import { RESTJSONErrorCodes, type Guild, type Snowflake } from 'discord.js'; + +export class ModerationActionBan extends ModerationAction { + public constructor() { + super({ + type: TypeVariation.Ban, + isUndoActionAvailable: true, + logPrefix: 'Moderation => Ban' + }); + } + + public override async isActive(guild: Guild, userId: Snowflake) { + const ban = await resolveOnErrorCodes(guild.bans.fetch({ user: userId, cache: false }), RESTJSONErrorCodes.UnknownBan); + return !isNullish(ban); + } + + protected override async handleApplyPost(guild: Guild, entry: ModerationAction.Entry, data: ModerationAction.Data) { + const reason = await this.getReason(guild, entry.reason); + await api().guilds.banUser(guild.id, entry.userId, { delete_message_seconds: data.context ?? 0 }, { reason }); + + await this.completeLastModerationEntryFromUser({ guild, userId: entry.userId }); + } + + protected override async handleUndoPre(guild: Guild, entry: ModerationAction.Entry) { + const reason = await this.getReason(guild, entry.reason, true); + await api().guilds.unbanUser(guild.id, entry.userId, { reason }); + + await this.completeLastModerationEntryFromUser({ guild, userId: entry.userId }); + } +} diff --git a/src/lib/moderation/actions/ModerationActionKick.ts b/src/lib/moderation/actions/ModerationActionKick.ts new file mode 100644 index 00000000000..d473e3879ad --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionKick.ts @@ -0,0 +1,18 @@ +import { api } from '#lib/discord/Api'; +import { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; +import { TypeVariation } from '#utils/moderationConstants'; +import type { Guild } from 'discord.js'; + +export class ModerationActionKick extends ModerationAction { + public constructor() { + super({ + type: TypeVariation.Kick, + isUndoActionAvailable: false, + logPrefix: 'Moderation => Kick' + }); + } + + protected override async handleApplyPost(guild: Guild, entry: ModerationAction.Entry) { + await api().guilds.removeMember(guild.id, entry.userId, { reason: await this.getReason(guild, entry.reason) }); + } +} diff --git a/src/lib/moderation/actions/ModerationActionRestrictedAll.ts b/src/lib/moderation/actions/ModerationActionRestrictedAll.ts new file mode 100644 index 00000000000..2374d965b78 --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionRestrictedAll.ts @@ -0,0 +1,25 @@ +import { RoleModerationAction } from '#lib/moderation/actions/base/RoleModerationAction'; +import { TypeVariation } from '#utils/moderationConstants'; +import { PermissionFlagsBits } from 'discord.js'; + +export class ModerationActionRestrictedAll extends RoleModerationAction { + public constructor() { + super({ + type: TypeVariation.Mute, + logPrefix: 'Moderation => Mute', + roleKey: RoleModerationAction.RoleKey.All, + roleData: { name: 'Muted', permissions: [], hoist: false, mentionable: false }, + roleOverridesText: + PermissionFlagsBits.SendMessages | + PermissionFlagsBits.SendMessagesInThreads | + PermissionFlagsBits.AddReactions | + PermissionFlagsBits.UseExternalEmojis | + PermissionFlagsBits.UseExternalStickers | + PermissionFlagsBits.UseApplicationCommands | + PermissionFlagsBits.CreatePublicThreads | + PermissionFlagsBits.CreatePrivateThreads, + roleOverridesVoice: PermissionFlagsBits.Connect, + replace: true + }); + } +} diff --git a/src/lib/moderation/actions/ModerationActionRestrictedAttachment.ts b/src/lib/moderation/actions/ModerationActionRestrictedAttachment.ts new file mode 100644 index 00000000000..1d30cb10b0d --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionRestrictedAttachment.ts @@ -0,0 +1,16 @@ +import { RoleModerationAction } from '#lib/moderation/actions/base/RoleModerationAction'; +import { TypeVariation } from '#utils/moderationConstants'; +import { PermissionFlagsBits } from 'discord.js'; + +export class ModerationActionRestrictedAttachment extends RoleModerationAction { + public constructor() { + super({ + type: TypeVariation.RestrictedAttachment, + logPrefix: 'Moderation => RestrictedAttachment', + roleKey: RoleModerationAction.RoleKey.Attachment, + roleData: { name: 'Attachment Restricted', permissions: [], hoist: false, mentionable: false }, + roleOverridesText: PermissionFlagsBits.AttachFiles, + roleOverridesVoice: null + }); + } +} diff --git a/src/lib/moderation/actions/ModerationActionRestrictedEmbed.ts b/src/lib/moderation/actions/ModerationActionRestrictedEmbed.ts new file mode 100644 index 00000000000..80f5764c315 --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionRestrictedEmbed.ts @@ -0,0 +1,16 @@ +import { RoleModerationAction } from '#lib/moderation/actions/base/RoleModerationAction'; +import { TypeVariation } from '#utils/moderationConstants'; +import { PermissionFlagsBits } from 'discord.js'; + +export class ModerationActionRestrictedEmbed extends RoleModerationAction { + public constructor() { + super({ + type: TypeVariation.RestrictedEmbed, + logPrefix: 'Moderation => RestrictedEmbed', + roleKey: RoleModerationAction.RoleKey.Embed, + roleData: { name: 'Embed Restricted', permissions: [], hoist: false, mentionable: false }, + roleOverridesText: PermissionFlagsBits.EmbedLinks, + roleOverridesVoice: null + }); + } +} diff --git a/src/lib/moderation/actions/ModerationActionRestrictedEmoji.ts b/src/lib/moderation/actions/ModerationActionRestrictedEmoji.ts new file mode 100644 index 00000000000..ec6b29e50e2 --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionRestrictedEmoji.ts @@ -0,0 +1,16 @@ +import { RoleModerationAction } from '#lib/moderation/actions/base/RoleModerationAction'; +import { TypeVariation } from '#utils/moderationConstants'; +import { PermissionFlagsBits } from 'discord.js'; + +export class ModerationActionRestrictedEmoji extends RoleModerationAction { + public constructor() { + super({ + type: TypeVariation.RestrictedEmoji, + logPrefix: 'Moderation => RestrictedEmoji', + roleKey: RoleModerationAction.RoleKey.Emoji, + roleData: { name: 'Emoji Restricted', permissions: [], hoist: false, mentionable: false }, + roleOverridesText: PermissionFlagsBits.UseExternalEmojis | PermissionFlagsBits.UseExternalStickers, + roleOverridesVoice: null + }); + } +} diff --git a/src/lib/moderation/actions/ModerationActionRestrictedReaction.ts b/src/lib/moderation/actions/ModerationActionRestrictedReaction.ts new file mode 100644 index 00000000000..d8809589ef0 --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionRestrictedReaction.ts @@ -0,0 +1,16 @@ +import { RoleModerationAction } from '#lib/moderation/actions/base/RoleModerationAction'; +import { TypeVariation } from '#utils/moderationConstants'; +import { PermissionFlagsBits } from 'discord.js'; + +export class ModerationActionRestrictedReaction extends RoleModerationAction { + public constructor() { + super({ + type: TypeVariation.RestrictedReaction, + logPrefix: 'Moderation => RestrictedReaction', + roleKey: RoleModerationAction.RoleKey.Reaction, + roleData: { name: 'Reaction Restricted', permissions: [], hoist: false, mentionable: false }, + roleOverridesText: PermissionFlagsBits.AddReactions, + roleOverridesVoice: null + }); + } +} diff --git a/src/lib/moderation/actions/ModerationActionRestrictedVoice.ts b/src/lib/moderation/actions/ModerationActionRestrictedVoice.ts new file mode 100644 index 00000000000..a1e07308ac8 --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionRestrictedVoice.ts @@ -0,0 +1,16 @@ +import { RoleModerationAction } from '#lib/moderation/actions/base/RoleModerationAction'; +import { TypeVariation } from '#utils/moderationConstants'; +import { PermissionFlagsBits } from 'discord.js'; + +export class ModerationActionRestrictedVoice extends RoleModerationAction { + public constructor() { + super({ + type: TypeVariation.RestrictedVoice, + logPrefix: 'Moderation => RestrictedVoice', + roleKey: RoleModerationAction.RoleKey.Voice, + roleData: { name: 'Voice Restricted', permissions: [], hoist: false, mentionable: false }, + roleOverridesText: null, + roleOverridesVoice: PermissionFlagsBits.Connect + }); + } +} diff --git a/src/lib/moderation/actions/ModerationActionRoleAdd.ts b/src/lib/moderation/actions/ModerationActionRoleAdd.ts new file mode 100644 index 00000000000..ac9d49f94a4 --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionRoleAdd.ts @@ -0,0 +1,49 @@ +import { api } from '#lib/discord/Api'; +import { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; +import { resolveOnErrorCodes } from '#utils/common'; +import { TypeVariation } from '#utils/moderationConstants'; +import { isNullish } from '@sapphire/utilities'; +import { RESTJSONErrorCodes, type Guild, type Role } from 'discord.js'; + +export class ModerationActionRoleAdd extends ModerationAction { + public constructor() { + super({ + type: TypeVariation.RoleAdd, + isUndoActionAvailable: true, + logPrefix: 'Moderation => RoleAdd' + }); + } + + public override async isActive(guild: Guild, userId: string, context: Role) { + const member = await resolveOnErrorCodes(guild.members.fetch(userId), RESTJSONErrorCodes.UnknownMember); + return !isNullish(member) && member.roles.cache.has(context.id); + } + + protected override async handleApplyPre(guild: Guild, entry: ModerationAction.Entry, data: ModerationAction.Data) { + const role = data.context!; + await api().guilds.addRoleToMember(guild.id, entry.userId, role.id, { + reason: await this.getReason(guild, entry.reason) + }); + + await this.completeLastModerationEntryFromUser({ + guild, + userId: entry.userId, + filter: (entry) => entry.extraData?.role === role.id + }); + } + + protected override async handleUndoPre(guild: Guild, entry: ModerationAction.Entry, data: ModerationAction.Data) { + const role = data.context!; + await api().guilds.removeRoleFromMember(guild.id, entry.userId, role.id, { reason: entry.reason ?? undefined }); + + await this.completeLastModerationEntryFromUser({ + guild, + userId: entry.userId, + filter: (entry) => entry.extraData?.role === role.id + }); + } + + protected override resolveOptionsExtraData(_guild: Guild, _options: ModerationAction.PartialOptions, data: ModerationAction.Data) { + return { role: data.context!.id }; + } +} diff --git a/src/lib/moderation/actions/ModerationActionRoleRemove.ts b/src/lib/moderation/actions/ModerationActionRoleRemove.ts new file mode 100644 index 00000000000..5492056a77c --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionRoleRemove.ts @@ -0,0 +1,49 @@ +import { api } from '#lib/discord/Api'; +import { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; +import { resolveOnErrorCodes } from '#utils/common'; +import { TypeVariation } from '#utils/moderationConstants'; +import { isNullish } from '@sapphire/utilities'; +import { RESTJSONErrorCodes, type Guild, type Role } from 'discord.js'; + +export class ModerationActionRoleRemove extends ModerationAction { + public constructor() { + super({ + type: TypeVariation.RoleRemove, + isUndoActionAvailable: true, + logPrefix: 'Moderation => RoleRemove' + }); + } + + public override async isActive(guild: Guild, userId: string, context: Role) { + const member = await resolveOnErrorCodes(guild.members.fetch(userId), RESTJSONErrorCodes.UnknownMember); + return !isNullish(member) && member.roles.cache.has(context.id); + } + + protected override async handleApplyPre(guild: Guild, entry: ModerationAction.Entry, data: ModerationAction.Data) { + const role = data.context!; + await api().guilds.removeRoleFromMember(guild.id, entry.userId, role.id, { + reason: await this.getReason(guild, entry.reason) + }); + + await this.completeLastModerationEntryFromUser({ + guild, + userId: entry.userId, + filter: (log) => log.extraData?.role === role.id + }); + } + + protected override async handleUndoPre(guild: Guild, entry: ModerationAction.Entry, data: ModerationAction.Data) { + const role = data.context!; + await api().guilds.addRoleToMember(guild.id, entry.userId, role.id, { reason: entry.reason ?? undefined }); + + await this.completeLastModerationEntryFromUser({ + guild, + userId: entry.userId, + filter: (log) => log.extraData?.role === role.id + }); + } + + protected override resolveOptionsExtraData(_guild: Guild, _options: ModerationAction.PartialOptions, data: ModerationAction.Data) { + return { role: data.context!.id }; + } +} diff --git a/src/lib/moderation/actions/ModerationActionSetNickname.ts b/src/lib/moderation/actions/ModerationActionSetNickname.ts new file mode 100644 index 00000000000..7e1b9bbc4ec --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionSetNickname.ts @@ -0,0 +1,47 @@ +import { api } from '#lib/discord/Api'; +import { LanguageKeys } from '#lib/i18n/languageKeys'; +import { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; +import { resolveOnErrorCodes } from '#utils/common'; +import { TypeVariation } from '#utils/moderationConstants'; +import { resolveKey } from '@sapphire/plugin-i18next'; +import { isNullish } from '@sapphire/utilities'; +import { RESTJSONErrorCodes, type Guild } from 'discord.js'; + +const Root = LanguageKeys.Commands.Moderation; + +export class ModerationActionSetNickname extends ModerationAction { + public constructor() { + super({ + type: TypeVariation.SetNickname, + isUndoActionAvailable: true, + logPrefix: 'Moderation => SetNickname' + }); + } + + public override async isActive(guild: Guild, userId: string, context: string | null) { + const member = await resolveOnErrorCodes(guild.members.fetch(userId), RESTJSONErrorCodes.UnknownMember); + return !isNullish(member) && member.nickname === context; + } + + protected override async handleApplyPre(guild: Guild, entry: ModerationAction.Entry, data: ModerationAction.Data) { + const nickname = data.context || null; + const reason = await (entry.reason + ? resolveKey(guild, nickname ? Root.ActionSetNicknameSet : Root.ActionSetNicknameRemoved, { reason: entry.reason }) + : resolveKey(guild, nickname ? Root.ActionSetNicknameNoReasonSet : Root.ActionSetNicknameNoReasonRemoved)); + await api().guilds.editMember(guild.id, entry.userId, { nick: nickname }, { reason }); + + await this.completeLastModerationEntryFromUser({ guild, userId: entry.userId }); + } + + protected override async handleUndoPre(guild: Guild, entry: ModerationAction.Entry, data: ModerationAction.Data) { + const nickname = data.context || null; + await api().guilds.editMember(guild.id, entry.userId, { nick: nickname }, { reason: entry.reason || undefined }); + + await this.completeLastModerationEntryFromUser({ guild, userId: entry.userId }); + } + + protected override async resolveOptionsExtraData(guild: Guild, options: ModerationAction.PartialOptions) { + const member = await guild.members.fetch(options.user); + return { oldName: member.nickname }; + } +} diff --git a/src/lib/moderation/actions/ModerationActionSoftBan.ts b/src/lib/moderation/actions/ModerationActionSoftBan.ts new file mode 100644 index 00000000000..836a1645986 --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionSoftBan.ts @@ -0,0 +1,36 @@ +import { api } from '#lib/discord/Api'; +import { LanguageKeys } from '#lib/i18n/languageKeys'; +import { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; +import { TypeVariation } from '#utils/moderationConstants'; +import { fetchT, type TFunction } from '@sapphire/plugin-i18next'; +import { isNullishOrEmpty } from '@sapphire/utilities'; +import type { Guild } from 'discord.js'; + +const Root = LanguageKeys.Commands.Moderation; + +export class ModerationActionSoftban extends ModerationAction { + public constructor() { + super({ + type: TypeVariation.Softban, + isUndoActionAvailable: false, + logPrefix: 'Moderation => Softban' + }); + } + + protected override async handleApplyPost(guild: Guild, entry: ModerationAction.Entry, data: ModerationAction.Data) { + const t = await fetchT(guild); + + await api().guilds.banUser(guild.id, entry.userId, { delete_message_seconds: data.context ?? 0 }, this.#getBanReason(t, entry.reason)); + await api().guilds.unbanUser(guild.id, entry.userId, this.#getUnbanReason(t, entry.reason)); + + await this.completeLastModerationEntryFromUser({ guild, userId: entry.userId, type: TypeVariation.Ban }); + } + + #getBanReason(t: TFunction, reason: string | null | undefined) { + return { reason: isNullishOrEmpty(reason) ? t(Root.ActionSoftBanNoReason) : t(Root.ActionSoftBanReason, { reason }) }; + } + + #getUnbanReason(t: TFunction, reason: string | null | undefined) { + return { reason: isNullishOrEmpty(reason) ? t(Root.ActionUnSoftBanNoReason) : t(Root.ActionUnSoftBanReason, { reason }) }; + } +} diff --git a/src/lib/moderation/actions/ModerationActionTimeout.ts b/src/lib/moderation/actions/ModerationActionTimeout.ts new file mode 100644 index 00000000000..e5294001015 --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionTimeout.ts @@ -0,0 +1,41 @@ +import { api } from '#lib/discord/Api'; +import { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; +import { resolveOnErrorCodes } from '#utils/common'; +import { TypeVariation } from '#utils/moderationConstants'; +import { isNullish } from '@sapphire/utilities'; +import { RESTJSONErrorCodes, type Guild, type Snowflake } from 'discord.js'; + +export class ModerationActionTimeout extends ModerationAction { + public constructor() { + super({ + type: TypeVariation.Timeout, + isUndoActionAvailable: true, + logPrefix: 'Moderation => Timeout' + }); + } + + public override async isActive(guild: Guild, userId: Snowflake) { + const member = await resolveOnErrorCodes(guild.members.fetch(userId), RESTJSONErrorCodes.UnknownMember); + return !isNullish(member) && member.isCommunicationDisabled(); + } + + protected override async handleApplyPre(guild: Guild, entry: ModerationAction.Entry, data: ModerationAction.Data) { + const reason = await this.getReason(guild, entry.reason); + const time = this.#getCommunicationDisabledUntil(data); + await api().guilds.editMember(guild.id, entry.userId, { communication_disabled_until: time }, { reason }); + + await this.completeLastModerationEntryFromUser({ guild, userId: entry.userId }); + } + + protected override async handleUndoPre(guild: Guild, entry: ModerationAction.Entry, data: ModerationAction.Data) { + const reason = await this.getReason(guild, entry.reason, true); + const time = this.#getCommunicationDisabledUntil(data); + await api().guilds.editMember(guild.id, entry.userId, { communication_disabled_until: time }, { reason }); + + await this.completeLastModerationEntryFromUser({ guild, userId: entry.userId }); + } + + #getCommunicationDisabledUntil(data: ModerationAction.Data) { + return isNullish(data.context) ? null : new Date(data.context).toISOString(); + } +} diff --git a/src/lib/moderation/actions/ModerationActionVoiceKick.ts b/src/lib/moderation/actions/ModerationActionVoiceKick.ts new file mode 100644 index 00000000000..5a50ff62b4b --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionVoiceKick.ts @@ -0,0 +1,19 @@ +import { api } from '#lib/discord/Api'; +import { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; +import { TypeVariation } from '#utils/moderationConstants'; +import type { Guild } from 'discord.js'; + +export class ModerationActionVoiceKick extends ModerationAction { + public constructor() { + super({ + type: TypeVariation.VoiceKick, + isUndoActionAvailable: false, + logPrefix: 'Moderation => VoiceKick' + }); + } + + protected override async handleApplyPost(guild: Guild, entry: ModerationAction.Entry) { + const reason = await this.getReason(guild, entry.reason); + await api().guilds.editMember(guild.id, entry.userId, { channel_id: null }, { reason }); + } +} diff --git a/src/lib/moderation/actions/ModerationActionVoiceMute.ts b/src/lib/moderation/actions/ModerationActionVoiceMute.ts new file mode 100644 index 00000000000..a41c95a711f --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionVoiceMute.ts @@ -0,0 +1,34 @@ +import { api } from '#lib/discord/Api'; +import { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; +import { resolveOnErrorCodes } from '#utils/common'; +import { TypeVariation } from '#utils/moderationConstants'; +import { RESTJSONErrorCodes, type Guild, type Snowflake } from 'discord.js'; + +export class ModerationActionVoiceMute extends ModerationAction { + public constructor() { + super({ + type: TypeVariation.VoiceMute, + isUndoActionAvailable: true, + logPrefix: 'Moderation => VoiceMute' + }); + } + + public override async isActive(guild: Guild, userId: Snowflake) { + const member = await resolveOnErrorCodes(guild.members.fetch(userId), RESTJSONErrorCodes.UnknownMember); + return member?.voice.serverMute ?? false; + } + + protected override async handleApplyPre(guild: Guild, entry: ModerationAction.Entry) { + const reason = await this.getReason(guild, entry.reason); + await api().guilds.editMember(guild.id, entry.userId, { mute: true }, { reason }); + + await this.completeLastModerationEntryFromUser({ guild, userId: entry.userId }); + } + + protected override async handleUndoPre(guild: Guild, entry: ModerationAction.Entry) { + const reason = await this.getReason(guild, entry.reason, true); + await api().guilds.editMember(guild.id, entry.userId, { mute: false }, { reason }); + + await this.completeLastModerationEntryFromUser({ guild, userId: entry.userId }); + } +} diff --git a/src/lib/moderation/actions/ModerationActionWarning.ts b/src/lib/moderation/actions/ModerationActionWarning.ts new file mode 100644 index 00000000000..a3a70f080fd --- /dev/null +++ b/src/lib/moderation/actions/ModerationActionWarning.ts @@ -0,0 +1,12 @@ +import { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; +import { TypeVariation } from '#utils/moderationConstants'; + +export class ModerationActionWarning extends ModerationAction { + public constructor() { + super({ + type: TypeVariation.Warning, + isUndoActionAvailable: false, + logPrefix: 'Moderation => Warning' + }); + } +} diff --git a/src/lib/moderation/actions/base/ModerationAction.ts b/src/lib/moderation/actions/base/ModerationAction.ts new file mode 100644 index 00000000000..41ab0242d5e --- /dev/null +++ b/src/lib/moderation/actions/base/ModerationAction.ts @@ -0,0 +1,325 @@ +import { LanguageKeys } from '#lib/i18n/languageKeys'; +import { getTitle, getTranslationKey } from '#lib/moderation/common'; +import type { ModerationManager } from '#lib/moderation/managers/ModerationManager'; +import type { TypedT } from '#lib/types'; +import { TypeMetadata, type TypeVariation } from '#lib/util/moderationConstants'; +import { getCodeStyle, getLogPrefix, getModeration } from '#utils/functions'; +import { getFullEmbedAuthor } from '#utils/util'; +import { EmbedBuilder } from '@discordjs/builders'; +import { container } from '@sapphire/framework'; +import { fetchT } from '@sapphire/plugin-i18next'; +import { isNullish, isNullishOrEmpty, isNullishOrZero, type Awaitable } from '@sapphire/utilities'; +import { DiscordAPIError, HTTPError, RESTJSONErrorCodes, type Guild, type Snowflake, type User } from 'discord.js'; + +const Root = LanguageKeys.Commands.Moderation; + +export abstract class ModerationAction { + /** + * Represents the type of moderation action. + */ + public readonly type: Type; + + /** + * Whether or not the action allows undoing. + */ + public readonly isUndoActionAvailable: boolean; + + /** + * The prefix used for logging moderation actions. + */ + protected readonly logPrefix: string; + + /** + * The key of the moderation action. + */ + protected readonly actionKey: TypedT; + + public constructor(options: ModerationAction.ConstructorOptions) { + this.type = options.type; + this.actionKey = getTranslationKey(this.type); + this.logPrefix = getLogPrefix(options.logPrefix); + this.isUndoActionAvailable = options.isUndoActionAvailable; + } + + /** + * Checks if this action is active for a given user in a guild. + * + * @param guild - The guild to check. + * @param userId - The ID of the user. + * @param context - The context for the action. + * @returns A boolean indicating whether the action is active. + */ + public isActive(guild: Guild, userId: Snowflake, context: ContextType): Awaitable; + public isActive() { + return false; + } + + /** + * Applies a moderation action to a user in the specified guild. + * + * @param guild - The guild to apply the moderation action at. + * @param options - The options for the moderation action. + * @param data - The options for sending the direct message. + * @returns A Promise that resolves to the created moderation entry. + */ + public async apply(guild: Guild, options: ModerationAction.PartialOptions, data: ModerationAction.Data = {}) { + const moderation = getModeration(guild); + const entry = moderation.create(await this.resolveOptions(guild, options, data)); + await this.handleApplyPre(guild, entry, data); + await this.sendDirectMessage(guild, entry, data); + await this.handleApplyPost(guild, entry, data); + return moderation.insert(entry); + } + + /** + * Undoes a moderation action to a user in the specified guild. + * + * @param guild - The guild to apply the moderation action at. + * @param options - The options for the moderation action. + * @param data - The options for sending the direct message. + * @returns A promise that resolves to the created entry. + */ + public async undo(guild: Guild, options: ModerationAction.PartialOptions, data: ModerationAction.Data = {}) { + const moderation = getModeration(guild); + const entry = moderation.create(await this.resolveAppealOptions(guild, options, data)); + await this.handleUndoPre(guild, entry, data); + await this.sendDirectMessage(guild, entry, data); + await this.handleUndoPost(guild, entry, data); + return moderation.insert(entry); + } + + /** + * Handles the pre-apply of the moderation action. Executed before the moderation entry is created and the user has + * been notified. + * + * @param guild - The guild to apply the moderation action at. + * @param entry - The draft moderation action. + */ + protected handleApplyPre(guild: Guild, entry: ModerationManager.Entry, data: ModerationAction.Data): Awaitable; + protected handleApplyPre() {} + + /** + * Handles the post-apply of the moderation action. Executed after the moderation entry is created and the user has + * been notified. + * + * @param guild - The guild to apply the moderation action at. + * @param entry - The draft moderation action. + */ + protected handleApplyPost(guild: Guild, entry: ModerationManager.Entry, data: ModerationAction.Data): Awaitable; + protected handleApplyPost() {} + + /** + * Handles the pre-undo of the moderation action. Executed before the moderation entry is created and the user has + * been notified. + * + * @param guild - The guild to undo a moderation action at. + * @param entry - The draft moderation action. + */ + protected handleUndoPre(guild: Guild, entry: ModerationManager.Entry, data: ModerationAction.Data): Awaitable; + protected handleUndoPre() {} + + /** + * Handles the post-undo of the moderation action. Executed after the moderation entry is created and the user has + * been notified. + * + * @param guild - The guild to undo a moderation action at. + * @param entry - The draft moderation action. + */ + protected handleUndoPost(guild: Guild, entry: ModerationManager.Entry, data: ModerationAction.Data): Awaitable; + protected handleUndoPost() {} + + protected async resolveOptions( + guild: Guild, + options: ModerationAction.PartialOptions, + data: ModerationAction.Data, + metadata: TypeMetadata = 0 + ) { + return { + ...options, + duration: options.duration || null, + type: this.type, + metadata, + extraData: options.extraData || (await this.resolveOptionsExtraData(guild, options, data)) + } satisfies ModerationManager.CreateData; + } + + /** + * Resolves the extra data for the moderation action. + * + * @param guild - The guild where the moderation action occurred. + * @param options - The original options for the moderation action. + * @param data - The options for sending the direct message. + */ + protected resolveOptionsExtraData( + guild: Guild, + options: ModerationAction.PartialOptions, + data: ModerationAction.Data + ): Awaitable>; + + protected resolveOptionsExtraData() { + return null as ModerationManager.ExtraData; + } + + /** + * Resolves the options for an appeal. + * + * @param options - The original options for the moderation action. + */ + protected async resolveAppealOptions(guild: Guild, options: ModerationAction.PartialOptions, data: ModerationAction.Data) { + return this.resolveOptions(guild, options, data, TypeMetadata.Undo); + } + + /** + * Sends a direct message to the user associated with the moderation entry. + * + * @param guild - The guild where the moderation action occurred. + * @param entry - The moderation entry. + * @param data - The options for sending the message. + */ + protected async sendDirectMessage(guild: Guild, entry: ModerationManager.Entry, data: ModerationAction.Data) { + if (!data.sendDirectMessage) return; + + try { + const target = await entry.fetchUser(); + const embed = await this.#buildEmbed(guild, entry, data); + await target.send({ embeds: [embed] }); + } catch (error) { + this.#handleDirectMessageError(error as Error); + } + } + + /** + * Retrieves the reason for a moderation action. + * + * @param guild - The guild where the moderation action occurred. + * @param reason - The reason for the moderation action. + * @param undo - Whether the action is an undo action. + * @returns The reason for the moderation action. + */ + protected async getReason(guild: Guild, reason: string | null | undefined, undo = false) { + const t = await fetchT(guild); + const action = t(this.actionKey); + return isNullishOrEmpty(reason) + ? t(undo ? Root.ActionRevokeNoReason : Root.ActionApplyNoReason, { action }) + : t(undo ? Root.ActionRevokeReason : Root.ActionApplyReason, { action, reason }); + } + + /** + * Cancels the last moderation entry task from a user. + * + * @param options - The options to fetch the moderation entry. + * @returns The canceled moderation entry, or `null` if no entry was found. + */ + protected async completeLastModerationEntryFromUser( + options: ModerationAction.ModerationEntryFetchOptions + ): Promise | null> { + const entry = await this.retrieveLastModerationEntryFromUser(options); + if (isNullish(entry)) return null; + + if (!isNullishOrZero(entry.duration)) { + await getModeration(options.guild).complete(entry); + } + return entry; + } + + /** + * Retrieves the last moderation entry from a user based on the provided options. + * + * @param options - The options for fetching the moderation entry. + * @returns The last moderation entry from the user, or `null` if no entry is found. + */ + protected async retrieveLastModerationEntryFromUser( + options: ModerationAction.ModerationEntryFetchOptions + ): Promise | null> { + // Retrieve all the entries + const entries = await getModeration(options.guild).fetch({ userId: options.userId }); + + const type = options.type ?? this.type; + const metadata = options.metadata ?? null; + const extra = options.filter ?? (() => true); + + for (const entry of entries.values()) { + // If the entry has been archived or has completed, skip it: + if (entry.isArchived() || entry.isCompleted()) continue; + // If the entry is not of the same type, skip it: + if (entry.type !== type) continue; + // If the entry is not of the same metadata, skip it: + if (metadata !== null && entry.metadata !== metadata) continue; + // If the extra check fails, skip it: + if (!extra(entry as ModerationManager.Entry)) continue; + + return entry as ModerationManager.Entry; + } + + return null; + } + + async #buildEmbed(guild: Guild, entry: ModerationManager.Entry, data: ModerationAction.Data) { + const descriptionKey = entry.reason + ? entry.duration + ? Root.ModerationDmDescriptionWithReasonWithDuration + : Root.ModerationDmDescriptionWithReason + : entry.duration + ? Root.ModerationDmDescriptionWithDuration + : Root.ModerationDmDescription; + + const t = await fetchT(guild); + const description = t(descriptionKey, { + guild: guild.name, + title: getTitle(t, entry), + reason: entry.reason, + duration: entry.duration + }); + const embed = new EmbedBuilder() // + .setDescription(description) + .setFooter({ text: t(Root.ModerationDmFooter) }); + + if (data.moderator) embed.setAuthor(getFullEmbedAuthor(data.moderator)); + return embed; + } + + #handleDirectMessageError(error: Error) { + if (error instanceof DiscordAPIError) return this.#handleDirectMessageDiscordError(error); + if (error instanceof HTTPError) return this.#handleDirectMessageHTTPError(error); + throw error; + } + + #handleDirectMessageDiscordError(error: DiscordAPIError) { + if (error.code === RESTJSONErrorCodes.CannotSendMessagesToThisUser) return; + + container.logger.error(this.logPrefix, getCodeStyle(error.code), error.url); + throw error; + } + + #handleDirectMessageHTTPError(error: HTTPError) { + container.logger.error(this.logPrefix, getCodeStyle(error.status), error.url); + throw error; + } +} + +export namespace ModerationAction { + export interface ConstructorOptions { + type: Type; + logPrefix: string; + isUndoActionAvailable: boolean; + } + + export type Options = ModerationManager.CreateData; + export type PartialOptions = Omit, 'type' | 'metadata'>; + + export type Entry = ModerationManager.Entry; + + export interface Data { + context?: ContextType; + sendDirectMessage?: boolean; + moderator?: User | null; + } + + export interface ModerationEntryFetchOptions { + guild: Guild; + userId: Snowflake; + type?: Type; + metadata?: TypeMetadata | null; + filter?: (entry: ModerationManager.Entry) => boolean; + } +} diff --git a/src/lib/moderation/actions/base/RoleModerationAction.ts b/src/lib/moderation/actions/base/RoleModerationAction.ts new file mode 100644 index 00000000000..86065a6d168 --- /dev/null +++ b/src/lib/moderation/actions/base/RoleModerationAction.ts @@ -0,0 +1,431 @@ +import { readSettings, writeSettings } from '#lib/database'; +import { LanguageKeys } from '#lib/i18n/languageKeys'; +import { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; +import type { GuildMessage } from '#lib/types'; +import { PermissionsBits } from '#utils/bits'; +import { resolveOnErrorCodes } from '#utils/common'; +import { getCodeStyle, getStickyRoles, promptConfirmation } from '#utils/functions'; +import type { TypeVariation } from '#utils/moderationConstants'; +import { GuildLimits, isCategoryChannel, isTextBasedChannel, isThreadChannel, isVoiceBasedChannel } from '@sapphire/discord.js-utilities'; +import { UserError, container, type Awaitable } from '@sapphire/framework'; +import { isNullish } from '@sapphire/utilities'; +import { + DiscordAPIError, + Guild, + GuildMember, + HTTPError, + PermissionFlagsBits, + RESTJSONErrorCodes, + Role, + inlineCode, + type NonThreadGuildBasedChannel, + type PermissionOverwriteOptions, + type RoleData, + type Snowflake +} from 'discord.js'; + +const Root = LanguageKeys.Commands.Moderation; + +interface Overrides { + bitfield: bigint; + array: readonly (keyof typeof PermissionFlagsBits)[]; + options: PermissionOverwriteOptions; +} + +export abstract class RoleModerationAction extends ModerationAction< + ContextType, + Type +> { + /** + * Represents the key of a role used in a moderation action. + */ + public readonly roleKey: RoleModerationAction.RoleKey; + + /** + * Indicates whether the existing roles should be replaced. + */ + protected readonly replace: boolean; + + /** + * Represents the data of a role for setup purposes. + */ + protected readonly roleData: RoleData; + + /** + * The representation of the role overrides for text-based channels. + */ + protected readonly roleOverridesText: Overrides; + /** + * The representation of the role overrides for voice-based channels. + */ + protected readonly roleOverridesVoice: Overrides; + /** + * The representation of the role overrides for generic and mixed channels. + */ + protected readonly roleOverridesMerged: Overrides; + + public constructor(options: RoleModerationAction.ConstructorOptions) { + super({ isUndoActionAvailable: true, ...options }); + this.replace = options.replace ?? false; + + this.roleKey = options.roleKey; + this.roleData = options.roleData; + + this.roleOverridesText = this.#resolveOverrides(options.roleOverridesText ?? 0n); + this.roleOverridesVoice = this.#resolveOverrides(options.roleOverridesVoice ?? 0n); + this.roleOverridesMerged = this.#resolveOverrides(this.roleOverridesText.bitfield | this.roleOverridesVoice.bitfield); + } + + public override async isActive(guild: Guild, userId: Snowflake) { + const roleId = await readSettings(guild, this.roleKey); + if (isNullish(roleId)) return false; + + const member = await resolveOnErrorCodes(guild.members.fetch(userId), RESTJSONErrorCodes.UnknownMember); + return !isNullish(member) && member.roles.cache.has(roleId); + } + + /** + * Sets up the role moderation action. + * + * @param message - The guild message that triggered the setup. + * @param guild - The guild where the setup is being performed. + * @returns A Promise that resolves once the setup is complete. + * @throws {UserError} If a mute role already exists or if there are too many roles in the guild. + */ + public async setup(message: GuildMessage) { + const { guild } = message; + const roleId = await readSettings(guild, this.roleKey); + if (roleId && guild.roles.cache.has(roleId)) throw new UserError({ identifier: Root.ActionSetupMuteExists }); + if (guild.roles.cache.size >= GuildLimits.MaximumRoles) throw new UserError({ identifier: Root.ActionSetupTooManyRoles }); + + const role = await guild.roles.create({ + ...this.roleData, + reason: `[Role Setup] Authorized by ${message.author.username} (${message.author.id}).` + }); + const t = await writeSettings(guild, (settings) => { + Reflect.set(settings, this.roleKey, role.id); + return settings.getLanguage(); + }); + + const manageableChannelCount = guild.channels.cache.reduce( + (acc, channel) => (!isThreadChannel(channel) && channel.manageable ? acc + 1 : acc), + 0 + ); + const permissions = this.roleOverridesMerged.array.map((key) => inlineCode(t(`permissions:${key}`))); + const content = t(Root.ActionSharedRoleSetupAsk, { role: role.name, channels: manageableChannelCount, permissions }); + if (await promptConfirmation(message, content)) { + await this.updateChannelsOverrides(guild, role); + } + } + + /** + * Updates the channel overrides for a given guild and role. + * + * This method iterates through all the channels in the guild, excluding threads, and updates the channel overrides + * for the specified role if the bot has the necessary permissions. + * + * @param guild - The guild where the channels are located. + * @param role - The role for which the channel overrides should be updated. + */ + public async updateChannelsOverrides(guild: Guild, role: Role) { + const channels = guild.channels.cache.values(); + for (const channel of channels) { + // Skip threads: + if (isThreadChannel(channel)) continue; + + // Skip if the bot can't manage the channel: + if (!channel.manageable) continue; + + // Update the channel overrides: + await this.updateChannelOverrides(channel, role); + } + } + + /** + * Updates the channel overrides for a given role. + * + * @param channel - The channel to update the overrides for. + * @param role - The role to update the overrides with. + * @returns A promise that resolves to `true` if the overrides were updated successfully, or `false` otherwise. + */ + public async updateChannelOverrides(channel: NonThreadGuildBasedChannel, role: Role) { + const options = this.#getChannelOverrides(channel); + if (options === null) return false; + + await channel.permissionOverwrites.edit(role, options); + return true; + } + + protected override handleApplyPre( + guild: Guild, + entry: ModerationAction.Entry, + data: ModerationAction.Data + ): Awaitable; + + protected override async handleApplyPre(guild: Guild, entry: ModerationAction.Entry) { + const member = await this.#fetchMember(guild, entry); + const role = await this.#fetchRole(guild); + + const me = await guild.members.fetchMe(); + if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) { + throw new UserError({ identifier: Root.ActionCannotManageRoles }); + } + + const { position } = me.roles.highest; + if (role.position >= position) { + throw new UserError({ identifier: Root.ActionRoleHigherPosition }); + } + + await getStickyRoles(guild).add(entry.userId, role.id); + + const reason = await this.getReason(guild, entry.reason); + const data = this.replace + ? await this.#handleApplyPreRolesReplace(member, role, reason, position) + : await this.#handleApplyPreRolesAdd(member, role, reason); + Reflect.set(entry, 'extraData' satisfies keyof typeof entry, data); + } + + protected override handleUndoPre(guild: Guild, entry: ModerationAction.Entry, data: ModerationAction.Data): Awaitable; + + protected override async handleUndoPre(guild: Guild, entry: ModerationAction.Entry) { + const member = await this.#fetchMember(guild, entry); + const role = await this.#fetchRole(guild); + + const me = await guild.members.fetchMe(); + if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) { + throw new UserError({ identifier: Root.ActionCannotManageRoles }); + } + + const { position } = me.roles.highest; + if (role.position >= position) { + throw new UserError({ identifier: Root.ActionRoleHigherPosition }); + } + + await getStickyRoles(guild).remove(entry.userId, role.id); + + const reason = await this.getReason(guild, entry.reason, true); + if (this.replace) { + await this.#handleUndoPreRolesReplace(member, role, reason, position); + } else { + await this.#handleUndoPreRolesRemove(member, role, reason); + } + } + + /** + * Handles the roles replace operation for a given member. + * + * This method extracts the roles from the member, adds the specified role, and updates the member's roles with the + * new set of roles. + * + * @param member - The guild member to apply the operation to. + * @param role - The role to add to the member. + * @param reason - The reason for applying the operation. + * @param position - The position of the role in the hierarchy. + * @returns An array of removed roles. + */ + async #handleApplyPreRolesReplace(member: GuildMember, role: Role, reason: string, position: number) { + const { keepRoles, removedRoles } = this.#extractRoles(member, position); + keepRoles.add(role.id); + + await member.edit({ roles: [...keepRoles], reason }); + return [...removedRoles]; + } + + /** + * Handles the apply action for adding the action role to a member. + * + * @param member - The guild member to apply the roles to. + * @param role - The role to add to the member. + * @param reason - The reason for adding the role. + * @returns A Promise that resolves to `null` when the role addition is complete. + */ + async #handleApplyPreRolesAdd(member: GuildMember, role: Role, reason: string) { + await member.roles.add(role, reason); + return null; + } + + /** + * Handles the undo operation for replacing pre-existing roles for a member. + * + * - If there is no previous moderation entry for the member, the specified role will be removed. + * - If there is a previous moderation entry, the pre-existing roles that are not managed and have a position + * lower than the specified position will be restored for the member. + * + * @param member - The guild member to handle the undo operation for. + * @param role - The role to remove if there is no previous moderation entry. + * @param reason - The reason for the undo operation. + * @param position - The position of the role that triggered the moderation action. + */ + async #handleUndoPreRolesReplace(member: GuildMember, role: Role, reason: string, position: number) { + const { guild } = member; + const entry = await this.completeLastModerationEntryFromUser({ guild, userId: member.id }); + if (isNullish(entry)) { + await member.roles.remove(role, reason); + return; + } + + const roles = new Set(member.roles.cache.keys()); + for (const roleId of Array.isArray(entry.extraData) ? entry.extraData : []) { + const role = member.guild.roles.cache.get(roleId); + // Add the ids that are: + // - In the cache. + // - Lower than Skyra's hierarchy position. + if (!isNullish(role) && !role.managed && role.position < position) roles.add(roleId); + } + + // Remove the action role from the set: + roles.delete(role.id); + + await member.edit({ roles: [...roles], reason }); + } + + /** + * Handles the undo action for removing the action role from a member. + * + * @param member - The guild member to remove the role from. + * @param role - The role to be removed. + * @param reason - The reason for removing the role. + */ + async #handleUndoPreRolesRemove(member: GuildMember, role: Role, reason: string) { + await member.roles.remove(role, reason); + } + + /** + * Retrieves the channel overrides for the given channel. + * If the channel is a category channel, it returns the merged role overrides options. + * If the channel is both a text-based and voice-based channel, it returns the merged role overrides options. + * If the channel is a text-based channel, it returns the text-based role overrides options. + * If the channel is a voice-based channel, it returns the voice-based role overrides options. + * If the channel does not match any of the above conditions, it returns null. + * + * @param channel - The channel to retrieve the overrides for. + * @returns The channel overrides options or null if no overrides are found. + */ + #getChannelOverrides(channel: NonThreadGuildBasedChannel) { + if (isCategoryChannel(channel)) return this.roleOverridesMerged.options; + + const isText = isTextBasedChannel(channel); + const isVoice = isVoiceBasedChannel(channel); + if (isText && isVoice) return this.roleOverridesMerged.options; + if (isText) return this.roleOverridesText.options; + if (isVoice) return this.roleOverridesVoice.options; + return null; + } + + /** + * Resolves the overrides for the given bitfield. + * + * @param bitfield - The bitfield to resolve overrides for. + * @returns The resolved overrides object. + */ + #resolveOverrides(bitfield: bigint): Overrides { + const array = PermissionsBits.toArray(bitfield); + const options = Object.fromEntries(array.map((key) => [key, false])); + return { bitfield, array, options }; + } + + /** + * Fetches the member from the guild using the provided options. + * + * @remarks + * If the member is not found, a {@link UserError} with the identifier {@link Root.ActionRequiredMember} is thrown. + * Otherwise, the error is re-thrown. + * + * @param guild The guild to fetch the member from. + * @param entry The entry containing the user ID. + * @returns A Promise that resolves to the fetched member. + */ + async #fetchMember(guild: Guild, entry: ModerationAction.Entry) { + try { + return await guild.members.fetch(entry.userId); + } catch (error) { + this.#handleFetchMemberError(error as Error); + } + } + + #handleFetchMemberError(error: Error): never { + if (error instanceof DiscordAPIError) this.#handleFetchMemberDiscordError(error); + if (error instanceof HTTPError) this.#handleFetchMemberHttpError(error); + throw error; + } + + #handleFetchMemberDiscordError(error: DiscordAPIError): never { + if (error.code === RESTJSONErrorCodes.UnknownMember) { + throw new UserError({ identifier: Root.ActionRequiredMember }); + } + + throw error; + } + + #handleFetchMemberHttpError(error: HTTPError): never { + container.logger.error(this.logPrefix, getCodeStyle(error.status), error.url); + throw error; + } + + /** + * Fetches the role associated with this moderation action from the guild. + * Throws an error if the role is not configured, doesn't exist, or is a managed role. + * + * @param guild - The guild to fetch the role from. + * @returns The fetched role. + * @throws If the role is not configured or if it is a managed role. + */ + async #fetchRole(guild: Guild) { + const roleId = await readSettings(guild, this.roleKey); + if (isNullish(roleId)) throw new UserError({ identifier: Root.ActionRoleNotConfigured }); + + const role = guild.roles.cache.get(roleId); + if (isNullish(role)) { + await writeSettings(guild, [[this.roleKey, null]]); + throw new UserError({ identifier: Root.ActionRoleNotConfigured }); + } + + if (role.managed) { + throw new UserError({ identifier: Root.ActionRoleManaged }); + } + + return role; + } + + #extractRoles(member: GuildMember, highestPosition: number) { + const keepRoles = new Set(); + const removedRoles = new Set(); + + // Iterate over all the member's roles. + for (const [id, role] of member.roles.cache.entries()) { + // Managed roles cannot be removed. + if (role.managed) keepRoles.add(id); + // Roles with higher hierarchy position cannot be removed. + else if (role.position >= highestPosition) keepRoles.add(id); + // Else it is fine to remove the role. + else removedRoles.add(id); + } + + return { keepRoles, removedRoles }; + } +} + +export namespace RoleModerationAction { + export interface ConstructorOptions + extends Omit, 'isUndoActionAvailable'> { + replace?: boolean; + roleKey: RoleKey; + roleData: RoleData; + roleOverridesText: bigint | null; + roleOverridesVoice: bigint | null; + } + + export type Options = ModerationAction.Options; + export type PartialOptions = ModerationAction.PartialOptions; + + export type Data = ModerationAction.Data; + + export const enum RoleKey { + All = 'rolesMuted', + Reaction = 'rolesRestrictedReaction', + Embed = 'rolesRestrictedEmbed', + Emoji = 'rolesRestrictedEmoji', + Attachment = 'rolesRestrictedAttachment', + Voice = 'rolesRestrictedVoice' + } +} diff --git a/src/lib/moderation/actions/index.ts b/src/lib/moderation/actions/index.ts new file mode 100644 index 00000000000..6fa8c73eba7 --- /dev/null +++ b/src/lib/moderation/actions/index.ts @@ -0,0 +1,72 @@ +import { ModerationActionBan } from '#lib/moderation/actions/ModerationActionBan'; +import { ModerationActionKick } from '#lib/moderation/actions/ModerationActionKick'; +import { ModerationActionRestrictedAll } from '#lib/moderation/actions/ModerationActionRestrictedAll'; +import { ModerationActionRestrictedAttachment } from '#lib/moderation/actions/ModerationActionRestrictedAttachment'; +import { ModerationActionRestrictedEmbed } from '#lib/moderation/actions/ModerationActionRestrictedEmbed'; +import { ModerationActionRestrictedEmoji } from '#lib/moderation/actions/ModerationActionRestrictedEmoji'; +import { ModerationActionRestrictedReaction } from '#lib/moderation/actions/ModerationActionRestrictedReaction'; +import { ModerationActionRestrictedVoice } from '#lib/moderation/actions/ModerationActionRestrictedVoice'; +import { ModerationActionRoleAdd } from '#lib/moderation/actions/ModerationActionRoleAdd'; +import { ModerationActionRoleRemove } from '#lib/moderation/actions/ModerationActionRoleRemove'; +import { ModerationActionSetNickname } from '#lib/moderation/actions/ModerationActionSetNickname'; +import { ModerationActionSoftban } from '#lib/moderation/actions/ModerationActionSoftBan'; +import { ModerationActionTimeout } from '#lib/moderation/actions/ModerationActionTimeout'; +import { ModerationActionVoiceKick } from '#lib/moderation/actions/ModerationActionVoiceKick'; +import { ModerationActionVoiceMute } from '#lib/moderation/actions/ModerationActionVoiceMute'; +import { ModerationActionWarning } from '#lib/moderation/actions/ModerationActionWarning'; +import type { RoleModerationAction } from '#lib/moderation/actions/base/RoleModerationAction'; +import { TypeVariation } from '#utils/moderationConstants'; +import type { ModerationAction } from './base/ModerationAction.js'; + +export const ModerationActions = { + ban: new ModerationActionBan(), + kick: new ModerationActionKick(), + mute: new ModerationActionRestrictedAll(), + timeout: new ModerationActionTimeout(), + restrictedAttachment: new ModerationActionRestrictedAttachment(), + restrictedEmbed: new ModerationActionRestrictedEmbed(), + restrictedEmoji: new ModerationActionRestrictedEmoji(), + restrictedReaction: new ModerationActionRestrictedReaction(), + restrictedVoice: new ModerationActionRestrictedVoice(), + roleAdd: new ModerationActionRoleAdd(), + roleRemove: new ModerationActionRoleRemove(), + setNickname: new ModerationActionSetNickname(), + softban: new ModerationActionSoftban(), + voiceKick: new ModerationActionVoiceKick(), + voiceMute: new ModerationActionVoiceMute(), + warning: new ModerationActionWarning() +} as const; + +export function getAction(type: Type): ActionByType { + return ModerationActions[ActionMappings[type]]; +} + +export type ActionByType = (typeof ModerationActions)[(typeof ActionMappings)[Type]]; +export type GetContextType = + ActionByType extends ModerationAction ? ContextType : never; + +const ActionMappings = { + [TypeVariation.RoleAdd]: 'roleAdd', + [TypeVariation.Ban]: 'ban', + [TypeVariation.Kick]: 'kick', + [TypeVariation.Mute]: 'mute', + [TypeVariation.Timeout]: 'timeout', + [TypeVariation.RoleRemove]: 'roleRemove', + [TypeVariation.RestrictedAttachment]: 'restrictedAttachment', + [TypeVariation.RestrictedEmbed]: 'restrictedEmbed', + [TypeVariation.RestrictedEmoji]: 'restrictedEmoji', + [TypeVariation.RestrictedReaction]: 'restrictedReaction', + [TypeVariation.RestrictedVoice]: 'restrictedVoice', + [TypeVariation.SetNickname]: 'setNickname', + [TypeVariation.Softban]: 'softban', + [TypeVariation.VoiceKick]: 'voiceKick', + [TypeVariation.VoiceMute]: 'voiceMute', + [TypeVariation.Warning]: 'warning' +} as const satisfies Readonly>; + +export type ModerationActionKey = keyof typeof ModerationActions; +export type RoleModerationActionKey = { + [K in ModerationActionKey]: (typeof ModerationActions)[K] extends RoleModerationAction ? K : never; +}[ModerationActionKey]; + +export type RoleTypeVariation = (typeof ModerationActions)[RoleModerationActionKey]['type']; diff --git a/src/lib/moderation/common/constants.ts b/src/lib/moderation/common/constants.ts new file mode 100644 index 00000000000..b2c53fa7ec5 --- /dev/null +++ b/src/lib/moderation/common/constants.ts @@ -0,0 +1,146 @@ +import { LanguageKeys } from '#lib/i18n/languageKeys'; +import type { TypedT } from '#lib/types'; +import { Colors } from '#utils/constants'; +import { TypeMetadata, TypeVariation } from '#utils/moderationConstants'; +import { isNullishOrZero } from '@sapphire/utilities'; +import type { ModerationManager } from '../managers/ModerationManager.js'; + +const Root = LanguageKeys.Moderation; + +export const TranslationMappings = { + [TypeVariation.Ban]: Root.TypeBan, + [TypeVariation.Kick]: Root.TypeKick, + [TypeVariation.Mute]: Root.TypeMute, + [TypeVariation.RestrictedAttachment]: Root.TypeRestrictedAttachment, + [TypeVariation.RestrictedEmbed]: Root.TypeRestrictedEmbed, + [TypeVariation.RestrictedEmoji]: Root.TypeRestrictedEmoji, + [TypeVariation.RestrictedReaction]: Root.TypeRestrictedReaction, + [TypeVariation.RestrictedVoice]: Root.TypeRestrictedVoice, + [TypeVariation.RoleAdd]: Root.TypeRoleAdd, + [TypeVariation.RoleRemove]: Root.TypeRoleRemove, + [TypeVariation.SetNickname]: Root.TypeSetNickname, + [TypeVariation.Softban]: Root.TypeSoftban, + [TypeVariation.Timeout]: Root.TypeTimeout, + [TypeVariation.VoiceKick]: Root.TypeVoiceKick, + [TypeVariation.VoiceMute]: Root.TypeVoiceMute, + [TypeVariation.Warning]: Root.TypeWarning +} as const satisfies Readonly>; + +export const UndoTaskNameMappings = { + [TypeVariation.Warning]: 'moderationEndWarning', + [TypeVariation.Mute]: 'moderationEndMute', + [TypeVariation.Ban]: 'moderationEndBan', + [TypeVariation.VoiceMute]: 'moderationEndVoiceMute', + [TypeVariation.RestrictedAttachment]: 'moderationEndRestrictionAttachment', + [TypeVariation.RestrictedReaction]: 'moderationEndRestrictionReaction', + [TypeVariation.RestrictedEmbed]: 'moderationEndRestrictionEmbed', + [TypeVariation.RestrictedEmoji]: 'moderationEndRestrictionEmoji', + [TypeVariation.RestrictedVoice]: 'moderationEndRestrictionVoice', + [TypeVariation.SetNickname]: 'moderationEndSetNickname', + [TypeVariation.RoleAdd]: 'moderationEndAddRole', + [TypeVariation.RoleRemove]: 'moderationEndRemoveRole' +} as const; + +const AllowedMetadataTypes = TypeMetadata.Undo | TypeMetadata.Temporary; +export function combineTypeData(type: TypeVariation, metadata?: TypeMetadata): TypeCodes { + if (isNullishOrZero(metadata)) return type as TypeCodes; + return (((metadata & AllowedMetadataTypes) << 5) | type) as TypeCodes; +} + +const TypeCodes = { + Ban: combineTypeData(TypeVariation.Ban), + Kick: combineTypeData(TypeVariation.Kick), + Mute: combineTypeData(TypeVariation.Mute), + RestrictedAttachment: combineTypeData(TypeVariation.RestrictedAttachment), + RestrictedEmbed: combineTypeData(TypeVariation.RestrictedEmbed), + RestrictedEmoji: combineTypeData(TypeVariation.RestrictedEmoji), + RestrictedReaction: combineTypeData(TypeVariation.RestrictedReaction), + RestrictedVoice: combineTypeData(TypeVariation.RestrictedVoice), + RoleAdd: combineTypeData(TypeVariation.RoleAdd), + RoleRemove: combineTypeData(TypeVariation.RoleRemove), + SetNickname: combineTypeData(TypeVariation.SetNickname), + SoftBan: combineTypeData(TypeVariation.Softban), + Timeout: combineTypeData(TypeVariation.Timeout), + VoiceKick: combineTypeData(TypeVariation.VoiceKick), + VoiceMute: combineTypeData(TypeVariation.VoiceMute), + Warning: combineTypeData(TypeVariation.Warning), + UndoBan: combineTypeData(TypeVariation.Ban, TypeMetadata.Undo), + UndoMute: combineTypeData(TypeVariation.Mute, TypeMetadata.Undo), + UndoRestrictedAttachment: combineTypeData(TypeVariation.RestrictedAttachment, TypeMetadata.Undo), + UndoRestrictedEmbed: combineTypeData(TypeVariation.RestrictedEmbed, TypeMetadata.Undo), + UndoRestrictedEmoji: combineTypeData(TypeVariation.RestrictedEmoji, TypeMetadata.Undo), + UndoRestrictedReaction: combineTypeData(TypeVariation.RestrictedReaction, TypeMetadata.Undo), + UndoRestrictedVoice: combineTypeData(TypeVariation.RestrictedVoice, TypeMetadata.Undo), + UndoRoleAdd: combineTypeData(TypeVariation.RoleAdd, TypeMetadata.Undo), + UndoRoleRemove: combineTypeData(TypeVariation.RoleRemove, TypeMetadata.Undo), + UndoSetNickname: combineTypeData(TypeVariation.SetNickname, TypeMetadata.Undo), + UndoTimeout: combineTypeData(TypeVariation.Timeout, TypeMetadata.Undo), + UndoVoiceMute: combineTypeData(TypeVariation.VoiceMute, TypeMetadata.Undo), + UndoWarning: combineTypeData(TypeVariation.Warning, TypeMetadata.Undo), + TemporaryBan: combineTypeData(TypeVariation.Ban, TypeMetadata.Temporary), + TemporaryMute: combineTypeData(TypeVariation.Mute, TypeMetadata.Temporary), + TemporaryRestrictedAttachment: combineTypeData(TypeVariation.RestrictedAttachment, TypeMetadata.Temporary), + TemporaryRestrictedEmbed: combineTypeData(TypeVariation.RestrictedEmbed, TypeMetadata.Temporary), + TemporaryRestrictedEmoji: combineTypeData(TypeVariation.RestrictedEmoji, TypeMetadata.Temporary), + TemporaryRestrictedReaction: combineTypeData(TypeVariation.RestrictedReaction, TypeMetadata.Temporary), + TemporaryRestrictedVoice: combineTypeData(TypeVariation.RestrictedVoice, TypeMetadata.Temporary), + TemporaryRoleAdd: combineTypeData(TypeVariation.RoleAdd, TypeMetadata.Temporary), + TemporaryRoleRemove: combineTypeData(TypeVariation.RoleRemove, TypeMetadata.Temporary), + TemporarySetNickname: combineTypeData(TypeVariation.SetNickname, TypeMetadata.Temporary), + TemporaryVoiceMute: combineTypeData(TypeVariation.VoiceMute, TypeMetadata.Temporary), + TemporaryWarning: combineTypeData(TypeVariation.Warning, TypeMetadata.Temporary) +} as const; + +export type TypeCodes = number & { __TYPE__: 'TypeCodes' }; + +export function isValidType(type: TypeVariation, metadata?: TypeMetadata): boolean { + return Metadata.has(combineTypeData(type, metadata)); +} + +export function getColor(entry: ModerationManager.Entry): number { + return Metadata.get(combineTypeData(entry.type, entry.metadata))!; +} + +const Metadata = new Map([ + [TypeCodes.Ban, Colors.Red], + [TypeCodes.Kick, Colors.Orange], + [TypeCodes.Mute, Colors.Amber], + [TypeCodes.RestrictedAttachment, Colors.Lime], + [TypeCodes.RestrictedEmbed, Colors.Lime], + [TypeCodes.RestrictedEmoji, Colors.Lime], + [TypeCodes.RestrictedReaction, Colors.Lime], + [TypeCodes.RestrictedVoice, Colors.Lime], + [TypeCodes.RoleAdd, Colors.Lime], + [TypeCodes.RoleRemove, Colors.Lime], + [TypeCodes.SetNickname, Colors.Lime], + [TypeCodes.SoftBan, Colors.DeepOrange], + [TypeCodes.Timeout, Colors.Amber], + [TypeCodes.VoiceKick, Colors.Orange], + [TypeCodes.VoiceMute, Colors.Amber], + [TypeCodes.Warning, Colors.Yellow], + [TypeCodes.UndoBan, Colors.LightBlue], + [TypeCodes.UndoMute, Colors.LightBlue], + [TypeCodes.UndoRestrictedAttachment, Colors.LightBlue], + [TypeCodes.UndoRestrictedEmbed, Colors.LightBlue], + [TypeCodes.UndoRestrictedEmoji, Colors.LightBlue], + [TypeCodes.UndoRestrictedReaction, Colors.LightBlue], + [TypeCodes.UndoRestrictedVoice, Colors.LightBlue], + [TypeCodes.UndoRoleAdd, Colors.LightBlue], + [TypeCodes.UndoRoleRemove, Colors.LightBlue], + [TypeCodes.UndoSetNickname, Colors.LightBlue], + [TypeCodes.UndoTimeout, Colors.LightBlue], + [TypeCodes.UndoVoiceMute, Colors.LightBlue], + [TypeCodes.UndoWarning, Colors.LightBlue], + [TypeCodes.TemporaryBan, Colors.Red300], + [TypeCodes.TemporaryMute, Colors.Amber300], + [TypeCodes.TemporaryRestrictedAttachment, Colors.Lime300], + [TypeCodes.TemporaryRestrictedEmbed, Colors.Lime300], + [TypeCodes.TemporaryRestrictedEmoji, Colors.Lime300], + [TypeCodes.TemporaryRestrictedReaction, Colors.Lime300], + [TypeCodes.TemporaryRestrictedVoice, Colors.Lime300], + [TypeCodes.TemporaryRoleAdd, Colors.Lime300], + [TypeCodes.TemporaryRoleRemove, Colors.Lime300], + [TypeCodes.TemporarySetNickname, Colors.Lime300], + [TypeCodes.TemporaryVoiceMute, Colors.Amber300], + [TypeCodes.TemporaryWarning, Colors.Yellow300] +]) as ReadonlyMap; diff --git a/src/lib/moderation/common/index.ts b/src/lib/moderation/common/index.ts new file mode 100644 index 00000000000..5936d1d2eaa --- /dev/null +++ b/src/lib/moderation/common/index.ts @@ -0,0 +1,2 @@ +export * from '#lib/moderation/common/constants'; +export * from '#lib/moderation/common/util'; diff --git a/src/lib/moderation/common/util.ts b/src/lib/moderation/common/util.ts new file mode 100644 index 00000000000..f1616dfeddb --- /dev/null +++ b/src/lib/moderation/common/util.ts @@ -0,0 +1,70 @@ +import { LanguageKeys } from '#lib/i18n/languageKeys'; +import { TranslationMappings, UndoTaskNameMappings, getColor } from '#lib/moderation/common/constants'; +import type { ModerationManager } from '#lib/moderation/managers/ModerationManager'; +import type { SkyraCommand } from '#lib/structures'; +import { seconds } from '#utils/common'; +import { TypeVariation } from '#utils/moderationConstants'; +import { getDisplayAvatar, getFullEmbedAuthor, getTag } from '#utils/util'; +import { EmbedBuilder } from '@discordjs/builders'; +import { container } from '@sapphire/framework'; +import type { TFunction } from '@sapphire/plugin-i18next'; +import { isNullishOrZero } from '@sapphire/utilities'; +import { TimestampStyles, chatInputApplicationCommandMention, time, type Snowflake } from 'discord.js'; + +export function getTranslationKey(type: Type): (typeof TranslationMappings)[Type] { + return TranslationMappings[type]; +} + +/** + * Retrieves the task name for the scheduled undo action based on the provided type. + * + * @param type - The type of the variation. + * @returns The undo task name associated with the provided type, or `null` if not found. + */ +export function getUndoTaskName(type: TypeVariation) { + return type in UndoTaskNameMappings ? UndoTaskNameMappings[type as keyof typeof UndoTaskNameMappings] : null; +} + +const Root = LanguageKeys.Moderation; +export function getTitle(t: TFunction, entry: ModerationManager.Entry): string { + const name = t(getTranslationKey(entry.type)); + if (entry.isUndo()) return t(Root.MetadataUndo, { name }); + if (entry.isTemporary()) return t(Root.MetadataTemporary, { name }); + return name; +} + +export async function getEmbed(t: TFunction, entry: ModerationManager.Entry) { + const [description, moderator] = await Promise.all([getEmbedDescription(t, entry), entry.fetchModerator()]); + const embed = new EmbedBuilder() + .setColor(getColor(entry)) + .setAuthor(getFullEmbedAuthor(moderator)) + .setDescription(description) + .setFooter({ + text: t(Root.EmbedFooter, { caseId: entry.id }), + iconURL: getDisplayAvatar(container.client.user!, { size: 128 }) + }) + .setTimestamp(entry.createdAt); + + if (entry.imageURL) embed.setImage(entry.imageURL); + return embed; +} + +async function getEmbedDescription(t: TFunction, entry: ModerationManager.Entry) { + const reason = entry.reason ?? t(Root.EmbedReasonNotSet, { command: getCaseEditMention(), caseId: entry.id }); + + const type = getTitle(t, entry); + const user = t(Root.EmbedUser, { tag: getTag(await entry.fetchUser()), id: entry.userId }); + return isNullishOrZero(entry.duration) + ? t(Root.EmbedDescription, { type, user, reason }) + : t(Root.EmbedDescriptionTemporary, { type, user, time: getEmbedDescriptionTime(entry.expiresTimestamp!), reason }); +} + +function getEmbedDescriptionTime(timestamp: number) { + return time(seconds.fromMilliseconds(timestamp), TimestampStyles.RelativeTime); +} + +let caseCommandId: Snowflake | null = null; +function getCaseEditMention() { + caseCommandId ??= (container.stores.get('commands').get('case') as SkyraCommand).getGlobalCommandId(); + return chatInputApplicationCommandMention('case', 'edit', caseCommandId); +} diff --git a/src/lib/moderation/index.ts b/src/lib/moderation/index.ts index c581dcf632e..44a49bbebb4 100644 --- a/src/lib/moderation/index.ts +++ b/src/lib/moderation/index.ts @@ -1,2 +1,3 @@ +export * from '#lib/moderation/actions/index'; export * from '#lib/moderation/managers/index'; export * from '#lib/moderation/structures/index'; diff --git a/src/lib/moderation/managers/ModerationManager.ts b/src/lib/moderation/managers/ModerationManager.ts index dd87ab4b26d..f2cceba4567 100644 --- a/src/lib/moderation/managers/ModerationManager.ts +++ b/src/lib/moderation/managers/ModerationManager.ts @@ -1,13 +1,16 @@ -import { ModerationEntity } from '#lib/database/entities'; import { GuildSettings } from '#lib/database/keys'; import { readSettings } from '#lib/database/settings'; -import { createReferPromise, floatPromise, seconds, type ReferredPromise } from '#utils/common'; +import { LanguageKeys } from '#lib/i18n/languageKeys'; +import { ModerationManagerEntry } from '#lib/moderation/managers/ModerationManagerEntry'; +import { SortedCollection } from '#lib/structures/data'; +import { Events } from '#lib/types'; +import { createReferPromise, desc, floatPromise, minutes, orMix, seconds, type BooleanFn, type ReferredPromise } from '#utils/common'; +import { TypeMetadata, TypeVariation } from '#utils/moderationConstants'; import { AsyncQueue } from '@sapphire/async-queue'; import type { GuildTextBasedChannelTypes } from '@sapphire/discord.js-utilities'; -import { container } from '@sapphire/framework'; -import { isNullish, type StrictRequired } from '@sapphire/utilities'; -import { Collection, DiscordAPIError, type Guild } from 'discord.js'; -import { In } from 'typeorm'; +import { UserError, container } from '@sapphire/framework'; +import { isNullish } from '@sapphire/utilities'; +import type { Guild, Snowflake } from 'discord.js'; enum CacheActions { None, @@ -15,38 +18,48 @@ enum CacheActions { Insert } -export class ModerationManager extends Collection { +export class ModerationManager { /** * The Guild instance that manages this manager */ - public guild: Guild; + public readonly guild: Guild; + + /** + * The cache of the moderation entries, sorted by their case ID in + * descending order. + */ + readonly #cache = new SortedCollection(undefined, desc); /** * A queue for save tasks, prevents case_id duplication */ - private saveQueue = new AsyncQueue(); + readonly #saveQueue = new AsyncQueue(); + + /** + * The latest moderation case ID. + */ + #latest: number | null = null; /** - * The current case count + * The amount of moderation cases in the database. */ - private _count: number | null = null; + #count: number | null = null; /** * The timer that sweeps this manager's entries */ - private _timer: NodeJS.Timeout | null = null; + #timer: NodeJS.Timeout | null = null; /** * The promise to wait for tasks to complete */ - private readonly _locks: ReferredPromise[] = []; + readonly #locks: ReferredPromise[] = []; private get db() { return container.db; } public constructor(guild: Guild) { - super(); this.guild = guild; } @@ -60,114 +73,173 @@ export class ModerationManager extends Collection { } /** - * Fetch 100 messages from the modlogs channel + * Retrieves the latest recent cached entry for a given user created in the last 30 seconds. + * + * @param userId - The ID of the user. + * @returns The latest recent cached entry for the user, or `null` if no entry is found. */ - public async fetchChannelMessages(remainingRetries = 5): Promise { - const channel = await this.fetchChannel(); - if (channel === null) return; + public getLatestRecentCachedEntryForUser(userId: string) { + const minimumTime = Date.now() - seconds(30); + for (const entry of this.#cache.values()) { + if (entry.userId !== userId) continue; + if (entry.createdAt < minimumTime) break; + return entry; + } + + return null; + } + + public create(data: ModerationManager.CreateData): ModerationManager.Entry { + return new ModerationManagerEntry({ + id: -1, + createdAt: -1, + duration: null, + extraData: null as ModerationManager.ExtraData, + guild: this.guild, + moderator: process.env.CLIENT_ID, + reason: null, + imageURL: null, + metadata: TypeMetadata.None, + ...data + }); + } + + public async insert(data: ModerationManager.Entry): Promise { + await this.#saveQueue.wait(); + try { - await channel.messages.fetch({ limit: 100 }); - } catch (error) { - if (error instanceof DiscordAPIError) throw error; - return this.fetchChannelMessages(--remainingRetries); + const id = (await this.getCurrentId()) + 1; + const entry = new ModerationManagerEntry({ ...data.toData(), id, createdAt: Date.now() }); + await this.#performInsert(entry); + return this.#addToCache(entry, CacheActions.Insert); + } finally { + this.#saveQueue.shift(); } } - public getLatestLogForUser(userId: string) { - if (this.size === 0) return null; - - const minimumTime = Date.now() - seconds(15); - return this.reduce( - (prev, curr) => - curr.userId === userId - ? prev === null - ? curr.createdTimestamp >= minimumTime - ? curr - : prev - : curr.createdTimestamp > prev.createdTimestamp - ? curr - : prev - : prev, - null - ); + /** + * Edits a moderation entry. + * + * @param entryOrId - The entry or ID of the moderation entry to edit. + * @param data - The updated data for the moderation entry. + * @returns The updated moderation entry. + */ + public async edit(entryOrId: ModerationManager.EntryResolvable, data: ModerationManager.UpdateData) { + const entry = await this.#resolveEntry(entryOrId); + return this.#performUpdate(entry, data); } - public create(data: ModerationManagerCreateData) { - return new ModerationEntity(data).setup(this); + /** + * Edits the {@linkcode ModerationManagerEntry.metadata} field from a moderation entry to set + * {@linkcode TypeMetadata.Archived}. + * + * @param entryOrId - The moderation entry or its ID. + * @returns The updated moderation entry. + */ + public async archive(entryOrId: ModerationManager.EntryResolvable) { + const entry = await this.#resolveEntry(entryOrId); + return this.#performUpdate(entry, { metadata: entry.metadata | TypeMetadata.Archived }); } - public async fetch(id: number): Promise; - public async fetch(id: string | number[]): Promise>; - public async fetch(id?: null): Promise; - public async fetch(id?: string | number | number[] | null): Promise | this | null> { + /** + * Edits the {@linkcode ModerationManagerEntry.metadata} field from a moderation entry to set + * {@linkcode TypeMetadata.Completed}. + * + * @param entryOrId - The moderation entry or its ID. + * @returns The updated moderation entry. + */ + public async complete(entryOrId: ModerationManager.EntryResolvable) { + const entry = await this.#resolveEntry(entryOrId); + return this.#performUpdate(entry, { metadata: entry.metadata | TypeMetadata.Completed }); + } + + /** + * Deletes a moderation entry. + * + * @param entryOrId - The moderation entry or its ID to delete. + * @returns The deleted moderation entry. + */ + public async delete(entryOrId: ModerationManager.EntryResolvable) { + const entry = await this.#resolveEntry(entryOrId); + + // Delete the task if it exists + const { task } = entry; + if (task) await task.delete(); + + // Delete the entry from the DB and the cache + await this.db.moderations.delete({ caseId: entry.id, guildId: entry.guild.id }); + this.#cache.delete(entry.id); + + return entry; + } + + /** + * Fetches a moderation entry from the cache or the database. + * + * @remarks + * + * If the entry is not found, it returns null. + * + * @param id - The ID of the moderation entry to fetch. + * @returns The fetched moderation entry, or `null` if it was not found. + */ + public async fetch(id: number): Promise; + /** + * Fetches multiple moderation entries from the cache or the database. + * + * @param options - The options to fetch the moderation entries. + * @returns The fetched moderation entries, sorted by + * {@link ModerationManagerEntry.id} in descending order. + */ + public async fetch(options?: ModerationManager.FetchOptions): Promise>; + public async fetch( + options: number | ModerationManager.FetchOptions = {} + ): Promise | null> { // Case number - if (typeof id === 'number') { - return ( - super.get(id) || this._cache(await this.db.fetchModerationEntry({ where: { guildId: this.guild.id, caseId: id } }), CacheActions.None) - ); + if (typeof options === 'number') { + return this.#getSingle(options) ?? this.#addToCache(await this.#fetchSingle(options), CacheActions.None); } - // User id - if (typeof id === 'string') { - return this._count === super.size - ? super.filter((entry) => entry.userId === id) - : this._cache(await this.db.fetchModerationEntries({ where: { guildId: this.guild.id, userId: id } }), CacheActions.None); + if (options.moderatorId || options.userId) { + return this.#count === this.#cache.size // + ? this.#getMany(options) + : this.#addToCache(await this.#fetchMany(options), CacheActions.None); } - if (Array.isArray(id) && id.length) { - return this._cache(await this.db.fetchModerationEntries({ where: { guildId: this.guild.id, caseId: In(id) } }), CacheActions.None); + if (this.#count !== this.#cache.size) { + this.#addToCache(await this.#fetchAll(), CacheActions.Fetch); } - if (super.size !== this._count) { - this._cache(await this.db.fetchModerationEntries({ where: { guildId: this.guild.id } }), CacheActions.Fetch); - } - return this; + return this.#cache; } - public async getCurrentId() { - if (this._count === null) { + public async getCurrentId(): Promise { + if (this.#latest === null) { const { moderations } = this.db; - const [{ max }] = (await moderations.query( + const [{ max, count }] = (await moderations.query( /* sql */ ` - SELECT max(case_id) + SELECT max(case_id), count(*) FROM "${moderations.metadata.tableName}" WHERE guild_id = $1; `, [this.guild.id] )) as [MaxQuery]; - this._count = max ?? 0; + this.#count = Number(count); + this.#latest = max ?? 0; } - return this._count; - } - - public async save(data: ModerationEntity) { - await this.saveQueue.wait(); - try { - data.caseId = (await this.getCurrentId()) + 1; - await data.save(); - this.insert(data); - } finally { - this.saveQueue.shift(); - } - } - - public insert(data: ModerationEntity): ModerationEntity; - public insert(data: ModerationEntity[]): Collection; - public insert(data: ModerationEntity | ModerationEntity[]) { - // @ts-expect-error TypeScript does not read the overloaded `data` parameter correctly - return this._cache(data, CacheActions.Insert); + return this.#latest; } public createLock() { // eslint-disable-next-line @typescript-eslint/no-invalid-void-type const lock = createReferPromise(); - this._locks.push(lock); + this.#locks.push(lock); floatPromise( lock.promise.finally(() => { - this._locks.splice(this._locks.indexOf(lock), 1); + this.#locks.splice(this.#locks.indexOf(lock), 1); }) ); @@ -175,49 +247,175 @@ export class ModerationManager extends Collection { } public releaseLock() { - for (const lock of this._locks) lock.resolve(); + for (const lock of this.#locks) lock.resolve(); } public waitLock() { - return Promise.all(this._locks.map((lock) => lock.promise)); + return Promise.all(this.#locks.map((lock) => lock.promise)); + } + + /** + * Checks if a moderation entry has been created for a given type and user + * within the last minute. + * + * @remarks + * + * This is useful to prevent duplicate moderation entries from being created + * when a user is banned, unbanned, or softbanned multiple times in a short. + * + * @param type - The type of moderation action. + * @param userId - The ID of the user. + * @returns A boolean indicating whether a moderation entry has been created. + */ + public checkSimilarEntryHasBeenCreated(type: TypeVariation, userId: Snowflake) { + const minimumTime = Date.now() - minutes(1); + const checkSoftBan = type === TypeVariation.Ban; + for (const entry of this.#cache.values()) { + // If it's not the same user target or if it's at least 1 minute old, skip: + if (userId !== entry.userId || entry.createdAt < minimumTime) continue; + + // If there was a log with the same type in the last minute, return true: + if (type === entry.type) return true; + + // If this log is a ban or an unban, but the user was softbanned recently, return true: + if (checkSoftBan && entry.type === TypeVariation.Softban) return true; + } + + // No similar entry has been created in the last minute: + return false; } - private _cache(entry: ModerationEntity | null, type: CacheActions): ModerationEntity; - private _cache(entries: ModerationEntity[], type: CacheActions): Collection; - private _cache( - entries: ModerationEntity | ModerationEntity[] | null, + #addToCache(entry: ModerationManagerEntry | null, type: CacheActions): ModerationManagerEntry; + #addToCache(entries: ModerationManagerEntry[], type: CacheActions): SortedCollection; + #addToCache( + entries: ModerationManagerEntry | ModerationManagerEntry[] | null, type: CacheActions - ): Collection | ModerationEntity | null { + ): SortedCollection | ModerationManagerEntry | null { if (!entries) return null; const parsedEntries = Array.isArray(entries) ? entries : [entries]; for (const entry of parsedEntries) { - super.set(entry.caseId, entry.setup(this)); + this.#cache.set(entry.id, entry); } - if (type === CacheActions.Insert) this._count! += parsedEntries.length; + if (type === CacheActions.Insert) { + this.#count! += parsedEntries.length; + this.#latest! += parsedEntries.length; + } - if (!this._timer) { - this._timer = setInterval(() => { - super.sweep((value) => value.cacheExpired); - if (!super.size) this._timer = null; - }, 1000); + if (!this.#timer) { + this.#timer = setInterval(() => { + this.#cache.sweep((value) => value.cacheExpired); + if (!this.#cache.size) this.#timer = null; + }, seconds(30)); } - return Array.isArray(entries) ? new Collection(entries.map((entry) => [entry.caseId, entry])) : entries; + return Array.isArray(entries) + ? new SortedCollection( + entries.map((entry) => [entry.id, entry]), + desc + ) + : entries; + } + + async #resolveEntry(entryOrId: ModerationManager.EntryResolvable) { + if (typeof entryOrId === 'number') { + const entry = await this.fetch(entryOrId); + if (isNullish(entry)) { + throw new UserError({ identifier: LanguageKeys.Arguments.CaseUnknownEntry, context: { parameter: entryOrId } }); + } + + return entry; + } + + if (entryOrId.guild.id !== this.guild.id) { + throw new UserError({ identifier: LanguageKeys.Arguments.CaseNotInThisGuild, context: { parameter: entryOrId.id } }); + } + + return entryOrId; + } + + #getSingle(id: number): ModerationManagerEntry | null { + return this.#cache.get(id) ?? null; } - public static get [Symbol.species]() { - return Collection; + async #fetchSingle(id: number): Promise { + const entity = await this.db.moderations.findOne({ where: { guildId: this.guild.id, caseId: id } }); + return entity && ModerationManagerEntry.from(this.guild, entity); + } + + #getMany(options: ModerationManager.FetchOptions): SortedCollection { + const fns: BooleanFn<[ModerationManagerEntry]>[] = []; + if (options.userId) fns.push((entry) => entry.userId === options.userId); + if (options.moderatorId) fns.push((entry) => entry.moderatorId === options.moderatorId); + + const fn = orMix(...fns); + return this.#cache.filter((entry) => fn(entry)); + } + + async #fetchMany(options: ModerationManager.FetchOptions): Promise { + const entities = await this.db.moderations.find({ + where: { + guildId: this.guild.id, + moderatorId: options.moderatorId, + userId: options.userId + } + }); + return entities.map((entity) => ModerationManagerEntry.from(this.guild, entity)); + } + + async #fetchAll(): Promise { + const entities = await this.db.moderations.find({ where: { guildId: this.guild.id } }); + return entities.map((entity) => ModerationManagerEntry.from(this.guild, entity)); + } + + async #performInsert(entry: ModerationManager.Entry) { + await this.db.moderations.insert({ + caseId: entry.id, + createdAt: new Date(entry.createdAt), + duration: entry.duration, + extraData: entry.extraData, + guildId: entry.guild.id, + moderatorId: entry.moderatorId, + userId: entry.userId, + reason: entry.reason, + imageURL: entry.imageURL, + type: entry.type, + metadata: entry.metadata + }); + + container.client.emit(Events.ModerationEntryAdd, entry); + return entry; + } + + async #performUpdate(entry: ModerationManager.Entry, data: ModerationManager.UpdateData) { + const result = await this.db.moderations.update({ caseId: entry.id, guildId: entry.guild.id }, data); + if (result.affected === 0) return entry; + + const clone = entry.clone(); + entry.patch(data); + container.client.emit(Events.ModerationEntryEdit, clone, entry); + return entry; } } +export namespace ModerationManager { + export interface FetchOptions { + userId?: Snowflake; + moderatorId?: Snowflake; + } + + export type Entry = Readonly>; + export type EntryResolvable = Entry | number; + + export type CreateData = ModerationManagerEntry.CreateData; + export type UpdateData = ModerationManagerEntry.UpdateData; + + export type ExtraData = ModerationManagerEntry.ExtraData; +} + interface MaxQuery { max: number | null; + count: `${bigint}`; } - -export type ModerationManagerUpdateData = Partial>; -export type ModerationManagerCreateData = Omit; -export type ModerationManagerInsertData = StrictRequired> & - Partial>; diff --git a/src/lib/moderation/managers/ModerationManagerEntry.ts b/src/lib/moderation/managers/ModerationManagerEntry.ts new file mode 100644 index 00000000000..69774ed8d1c --- /dev/null +++ b/src/lib/moderation/managers/ModerationManagerEntry.ts @@ -0,0 +1,360 @@ +import type { ModerationEntity, ScheduleEntity } from '#lib/database'; +import { LanguageKeys } from '#lib/i18n/languageKeys'; +import { minutes } from '#utils/common'; +import { SchemaKeys, TypeMetadata, type TypeVariation } from '#utils/moderationConstants'; +import { UserError, container } from '@sapphire/framework'; +import { isNullishOrZero } from '@sapphire/utilities'; +import type { Guild, Snowflake, User } from 'discord.js'; + +/** + * Represents a moderation manager entry. + */ +export class ModerationManagerEntry { + /** + * The ID of this moderation entry. + */ + public readonly id: number; + + /** + * The timestamp when the moderation entry was created. + */ + public readonly createdAt: number; + + /** + * The duration of the moderation entry since the creation. + * + * @remarks + * + * The value can be updated to add or remove the duration. + */ + public duration!: number | null; + + /** + * The extra data of the moderation entry. + */ + public readonly extraData: ExtraDataTypes[Type]; + + /** + * The guild where the moderation entry was created. + */ + public readonly guild: Guild; + + /** + * The ID of the moderator who created the moderation entry. + */ + public readonly moderatorId: Snowflake; + + /** + * The ID of the user who is the target of the moderation entry. + */ + public readonly userId: Snowflake; + + /** + * The reason of the action in the moderation entry. + * + * @remarks + * + * The value can be updated to add or remove the reason. + */ + public reason: string | null; + + /** + * The image URL of the moderation entry. + * + * @remarks + * + * The value can be updated to add or remove the image URL. + */ + public imageURL: string | null; + + /** + * The type of the moderation entry. + */ + public readonly type: Type; + + /** + * The metadata of the moderation entry. + * + * @remarks + * + * The metadata is a bitfield that contains the following information: + * - `1 << 0`: The moderation entry is an undo action. + * - `1 << 1`: The moderation entry is temporary. + * - `1 << 3`: The moderation entry is archived. + * + * The value can be updated adding or removing any of the aforementioned + * flags. + */ + public metadata: TypeMetadata; + + #moderator: User | null; + #user: User | null; + #cacheExpiresTimeout = Date.now() + minutes(15); + + /** + * Constructs a new `ModerationManagerEntry` instance. + * + * @param data - The data to initialize the entry. + */ + public constructor(data: ModerationManagerEntry.Data) { + this.id = data.id; + this.createdAt = data.createdAt; + this.extraData = data.extraData; + this.guild = data.guild; + this.reason = data.reason; + this.imageURL = data.imageURL; + this.type = data.type; + this.metadata = data.metadata; + + this.#setDuration(data.duration); + + if (typeof data.moderator === 'string') { + this.#moderator = null; + this.moderatorId = data.moderator; + } else { + this.#moderator = data.moderator; + this.moderatorId = data.moderator.id; + } + + if (typeof data.user === 'string') { + this.#user = null; + this.userId = data.user; + } else { + this.#user = data.user; + this.userId = data.user.id; + } + } + + /** + * Creates a new instance of `ModerationManagerEntry` with the same property values as the current instance. + */ + public clone() { + return new ModerationManagerEntry(this.toData()); + } + + /** + * Updates the moderation entry with the given data. + * + * @remarks + * + * This method does not update the database, it only updates the instance + * with the given data, and updates the cache expiration time. + * + * @param data - The data to update the entry. + */ + public patch(data: ModerationManagerEntry.UpdateData) { + if (data.duration !== undefined) this.#setDuration(data.duration); + if (data.reason !== undefined) this.reason = data.reason; + if (data.imageURL !== undefined) this.imageURL = data.imageURL; + if (data.metadata !== undefined) this.metadata = data.metadata; + + this.#cacheExpiresTimeout = Date.now() + minutes(15); + } + + /** + * The scheduled task for this moderation entry. + */ + public get task() { + return container.client.schedules.queue.find((task) => this.#isMatchingTask(task)) ?? null; + } + + /** + * The timestamp when the moderation entry expires, if any. + * + * @remarks + * + * If {@linkcode duration} is `null` or `0`, this property will be `null`. + */ + public get expiresTimestamp() { + return isNullishOrZero(this.duration) ? null : this.createdAt + this.duration; + } + + /** + * Whether the moderation entry is expired. + * + * @remarks + * + * If {@linkcode expiresTimestamp} is `null`, this property will always be + * `false`. + */ + public get expired() { + const { expiresTimestamp } = this; + return expiresTimestamp !== null && expiresTimestamp < Date.now(); + } + + /** + * Whether the moderation entry is cache expired, after 15 minutes. + * + * @remarks + * + * This property is used to determine if the entry should be removed from + * the cache, and will be updated to extend the cache life when + * {@linkcode patch} is called. + */ + public get cacheExpired() { + return this.#cacheExpiresTimeout < Date.now(); + } + + /** + * Checks if the entry is an undo action. + */ + public isUndo() { + return (this.metadata & TypeMetadata.Undo) === TypeMetadata.Undo; + } + + /** + * Checks if the entry is temporary. + */ + public isTemporary() { + return (this.metadata & TypeMetadata.Temporary) === TypeMetadata.Temporary; + } + + /** + * Checks if the entry is archived. + */ + public isArchived() { + return (this.metadata & TypeMetadata.Archived) === TypeMetadata.Archived; + } + + /** + * Checks if the entry is completed. + */ + public isCompleted() { + return (this.metadata & TypeMetadata.Completed) === TypeMetadata.Completed; + } + + /** + * Fetches the moderator who created the moderation entry. + */ + public async fetchModerator() { + return (this.#moderator ??= await container.client.users.fetch(this.moderatorId)); + } + + /** + * Fetches the target user of the moderation entry. + */ + public async fetchUser() { + return (this.#user ??= await container.client.users.fetch(this.userId)); + } + + /** + * Returns a clone of the data for this moderation manager entry. + */ + public toData(): ModerationManagerEntry.Data { + return { + id: this.id, + createdAt: this.createdAt, + duration: this.duration, + extraData: this.extraData, + guild: this.guild, + moderator: this.moderatorId, + user: this.userId, + reason: this.reason, + imageURL: this.imageURL, + type: this.type, + metadata: this.metadata + }; + } + + public toJSON() { + return { + id: this.id, + createdAt: this.createdAt, + duration: this.duration, + extraData: this.extraData, + guildId: this.guild.id, + moderatorId: this.moderatorId, + userId: this.userId, + reason: this.reason, + imageURL: this.imageURL, + type: this.type, + metadata: this.metadata + }; + } + + #isMatchingTask(task: ScheduleEntity) { + return ( + typeof task.data === 'object' && + task.data !== null && + task.data[SchemaKeys.Case] === this.id && + task.data[SchemaKeys.Guild] === this.guild.id + ); + } + + #setDuration(duration: number | null) { + if (isNullishOrZero(duration)) { + this.duration = null; + this.metadata &= ~TypeMetadata.Temporary; + } else { + this.duration = duration; + this.metadata |= TypeMetadata.Temporary; + } + } + + public static from(guild: Guild, entity: ModerationEntity) { + if (guild.id !== entity.guildId) { + throw new UserError({ identifier: LanguageKeys.Arguments.CaseNotInThisGuild, context: { parameter: entity.caseId } }); + } + + return new this({ + id: entity.caseId, + createdAt: entity.createdAt ? entity.createdAt.getTime() : Date.now(), + duration: entity.duration, + extraData: entity.extraData as any, + guild, + moderator: entity.moderatorId, + user: entity.userId!, + reason: entity.reason, + imageURL: entity.imageURL, + type: entity.type, + metadata: entity.metadata + }); + } +} + +export namespace ModerationManagerEntry { + export interface Data { + id: number; + createdAt: number; + duration: number | null; + extraData: ExtraData; + guild: Guild; + moderator: User | Snowflake; + user: User | Snowflake; + reason: string | null; + imageURL: string | null; + type: Type; + metadata: TypeMetadata; + } + + export type CreateData = MakeOptional< + Omit, 'id' | 'guild' | 'createdAt'>, + 'duration' | 'imageURL' | 'extraData' | 'metadata' | 'moderator' | 'reason' + >; + export type UpdateData = Partial< + Omit, 'id' | 'createdAt' | 'extraData' | 'moderator' | 'user' | 'type' | 'guild'> + >; + + export type ExtraData = ExtraDataTypes[Type]; +} + +type MakeOptional = Omit & Partial>; + +interface ExtraDataTypes { + [TypeVariation.Ban]: null; + [TypeVariation.Kick]: null; + [TypeVariation.Mute]: Snowflake[]; + [TypeVariation.Softban]: null; + [TypeVariation.VoiceKick]: null; + [TypeVariation.VoiceMute]: null; + [TypeVariation.Warning]: null; + [TypeVariation.RestrictedReaction]: null; + [TypeVariation.RestrictedEmbed]: null; + [TypeVariation.RestrictedAttachment]: null; + [TypeVariation.RestrictedVoice]: null; + [TypeVariation.SetNickname]: { oldName: string | null }; + [TypeVariation.RoleAdd]: { role: Snowflake }; + [TypeVariation.RoleRemove]: { role: Snowflake }; + [TypeVariation.RestrictedEmoji]: null; + [TypeVariation.Timeout]: null; +} diff --git a/src/lib/moderation/managers/index.ts b/src/lib/moderation/managers/index.ts index f56fc222ad3..2a0a132b977 100644 --- a/src/lib/moderation/managers/index.ts +++ b/src/lib/moderation/managers/index.ts @@ -1,4 +1,5 @@ export * from '#lib/moderation/managers/LoggerManager'; export * from '#lib/moderation/managers/LoggerTypeManager'; export * from '#lib/moderation/managers/ModerationManager'; +export * from '#lib/moderation/managers/ModerationManagerEntry'; export * from '#lib/moderation/managers/StickyRoleManager'; diff --git a/src/lib/moderation/structures/ModerationCommand.ts b/src/lib/moderation/structures/ModerationCommand.ts index e948e9974c6..466364bf5f8 100644 --- a/src/lib/moderation/structures/ModerationCommand.ts +++ b/src/lib/moderation/structures/ModerationCommand.ts @@ -1,39 +1,61 @@ -import { GuildSettings, ModerationEntity, readSettings } from '#lib/database'; +import { GuildSettings, readSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; +import { getAction, type ActionByType, type GetContextType } from '#lib/moderation/actions'; +import type { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; +import type { ModerationManager } from '#lib/moderation/managers/ModerationManager'; import { SkyraCommand } from '#lib/structures/commands/SkyraCommand'; -import { PermissionLevels, type GuildMessage } from '#lib/types'; -import { floatPromise, seconds, years } from '#utils/common'; +import { PermissionLevels, type GuildMessage, type TypedT } from '#lib/types'; +import { asc, floatPromise, seconds, years } from '#utils/common'; import { deleteMessage, isGuildOwner } from '#utils/functions'; -import type { ModerationActionsSendOptions } from '#utils/Security/ModerationActions'; -import { cast, getTag } from '#utils/util'; -import { Args, CommandOptionsRunTypeEnum } from '@sapphire/framework'; +import type { TypeVariation } from '#utils/moderationConstants'; +import { getImage, getTag, isUserSelf } from '#utils/util'; +import { Args, CommandOptionsRunTypeEnum, type Awaitable } from '@sapphire/framework'; import { free, send } from '@sapphire/plugin-editable-commands'; import type { User } from 'discord.js'; -export abstract class ModerationCommand extends SkyraCommand { +const Root = LanguageKeys.Moderation; + +export abstract class ModerationCommand extends SkyraCommand { /** - * Whether a member is required or not. + * The moderation action this command applies. + */ + protected readonly action: ActionByType; + + /** + * Whether this command executes an undo action. */ - public requiredMember: boolean; + protected readonly isUndoAction: boolean; /** - * Whether or not this moderation command can create temporary actions. + * The key for the action is active language key. */ - public optionalDuration: boolean; + protected readonly actionStatusKey: TypedT; - protected constructor(context: ModerationCommand.Context, options: ModerationCommand.Options) { + /** + * Whether this command supports schedules. + */ + protected readonly supportsSchedule: boolean; + + /** + * Whether a member is required or not. + */ + protected readonly requiredMember: boolean; + + protected constructor(context: ModerationCommand.Context, options: ModerationCommand.Options) { super(context, { cooldownDelay: seconds(5), flags: ['no-author', 'authored', 'no-dm', 'dm'], - optionalDuration: false, permissionLevel: PermissionLevels.Moderator, requiredMember: false, runIn: [CommandOptionsRunTypeEnum.GuildAny], ...options }); - this.requiredMember = options.requiredMember!; - this.optionalDuration = options.optionalDuration!; + this.action = getAction(options.type); + this.isUndoAction = options.isUndoAction ?? false; + this.actionStatusKey = options.actionStatusKey ?? (this.isUndoAction ? Root.ActionIsNotActive : Root.ActionIsActive); + this.supportsSchedule = this.action.isUndoActionAvailable && !this.isUndoAction; + this.requiredMember = options.requiredMember ?? false; } public override messageRun( @@ -43,9 +65,9 @@ export abstract class ModerationCommand extends SkyraCommand { ): Promise; public override async messageRun(message: GuildMessage, args: ModerationCommand.Args) { - const resolved = await this.resolveOverloads(args); - const preHandled = await this.prehandle(message, resolved); - const processed = [] as Array<{ log: ModerationEntity; target: User }>; + const resolved = await this.resolveParameters(args); + const preHandled = await this.preHandle(message, resolved); + const processed = [] as Array<{ log: ModerationManager.Entry; target: User }>; const errored = [] as Array<{ error: Error | string; target: User }>; const [shouldAutoDelete, shouldDisplayMessage, shouldDisplayReason] = await readSettings(message.guild, [ @@ -58,7 +80,7 @@ export abstract class ModerationCommand extends SkyraCommand { for (const target of new Set(targets)) { try { const handled = { ...handledRaw, args, target, preHandled }; - await this.checkModeratable(message, handled); + await this.checkTargetCanBeModerated(message, handled); const log = await this.handle(message, handled); processed.push({ log, target }); } catch (error) { @@ -67,7 +89,7 @@ export abstract class ModerationCommand extends SkyraCommand { } try { - await this.posthandle(message, { ...resolved, preHandled }); + await this.postHandle(message, { ...resolved, preHandled }); } catch { // noop } @@ -80,15 +102,15 @@ export abstract class ModerationCommand extends SkyraCommand { if (shouldDisplayMessage) { const output: string[] = []; if (processed.length) { - const logReason = shouldDisplayReason ? processed[0].log.reason! : null; - const sorted = processed.sort((a, b) => a.log.caseId - b.log.caseId); - const cases = sorted.map(({ log }) => log.caseId); + const reason = shouldDisplayReason ? processed[0].log.reason! : null; + const sorted = processed.sort((a, b) => asc(a.log.id, b.log.id)); + const cases = sorted.map(({ log }) => log.id); const users = sorted.map(({ target }) => `\`${getTag(target)}\``); const range = cases.length === 1 ? cases[0] : `${cases[0]}..${cases[cases.length - 1]}`; - const langKey = logReason + const key = reason // ? LanguageKeys.Commands.Moderation.ModerationOutputWithReason : LanguageKeys.Commands.Moderation.ModerationOutput; - output.push(args.t(langKey, { count: cases.length, range, users, reason: logReason })); + output.push(args.t(key, { count: cases.length, range, users, reason })); } if (errored.length) { @@ -113,23 +135,118 @@ export abstract class ModerationCommand extends SkyraCommand { return null; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected prehandle(_message: GuildMessage, _context: CommandContext): Promise | T { - return cast(null); + /** + * Handles an action before taking the moderation action. + * + * @param message - The message that triggered the command. + * @param context - The context for the moderation command, shared for all targets. + * @returns The value that will be set in {@linkcode ModerationCommand.HandlerParameters.preHandled}. + */ + protected preHandle(message: GuildMessage, context: ModerationCommand.Parameters): Awaitable; + protected preHandle() { + return null as ValueType; + } + + /** + * Handles the moderation action. + * + * @param message - The message that triggered the command. + * @param context - The context for the moderation command, for a single target. + */ + protected handle( + message: GuildMessage, + context: ModerationCommand.HandlerParameters + ): Promise | ModerationManager.Entry; + + protected async handle(message: GuildMessage, context: ModerationCommand.HandlerParameters) { + const dataContext = this.getHandleDataContext(message, context); + + const options = this.resolveOptions(message, context); + const data = await this.getActionData(message, context.args, context.target, dataContext); + const isActive = await this.isActionActive(message, context, dataContext); + + if (this.isUndoAction) { + // If this command is an undo action, and the action is not active, throw an error. + if (!isActive) { + throw context.args.t(this.getActionStatusKey(context)); + } + + // @ts-expect-error mismatching types due to unions + return this.action.undo(message.guild, options, data); + } + + // If this command is not an undo action, and the action is active, throw an error. + if (isActive) { + throw context.args.t(this.getActionStatusKey(context)); + } + + // @ts-expect-error mismatching types due to unions + return this.action.apply(message.guild, options, data); + } + + /** + * Gets the data context required for some actions, if any. + * + * @param message - The message that triggered the command. + * @param context - The context for the moderation command, for a single target. + */ + protected getHandleDataContext(message: GuildMessage, context: ModerationCommand.HandlerParameters): GetContextType; + protected getHandleDataContext(): GetContextType { + return null as GetContextType; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected posthandle(_message: GuildMessage, _context: PostHandledCommandContext): unknown { + /** + * Checks if the action is active. + * + * @param message - The message that triggered the command. + * @param context - The context for the moderation command, for a single target. + * @param dataContext - The data context required for some actions, if any. + * @returns + */ + protected isActionActive( + message: GuildMessage, + context: ModerationCommand.HandlerParameters, + dataContext: GetContextType + ): Awaitable { + return this.action.isActive(message.guild, context.target.id, dataContext as never); + } + + /** + * Gets the key for the action status language key. + * + * @remarks + * + * Unless overridden, this method just returns the value of {@linkcode ModerationCommand.actionStatusKey}. + * + * @param context - The context for the moderation command, for a single target. + */ + protected getActionStatusKey(context: ModerationCommand.HandlerParameters): TypedT; + protected getActionStatusKey(): TypedT { + return this.actionStatusKey; + } + + /** + * Handles an action after taking the moderation action. + * + * @param message - The message that triggered the command. + * @param context - The context for the moderation command, shared for all targets. + */ + protected postHandle(message: GuildMessage, context: ModerationCommand.PostHandleParameters): unknown; + protected postHandle() { return null; } - protected async checkModeratable(message: GuildMessage, context: HandledCommandContext) { + protected async checkTargetCanBeModerated(message: GuildMessage, context: ModerationCommand.HandlerParameters) { if (context.target.id === message.author.id) { - throw context.args.t(LanguageKeys.Commands.Moderation.UserSelf); + throw context.args.t(Root.ActionTargetSelf); } - if (context.target.id === process.env.CLIENT_ID) { - throw context.args.t(LanguageKeys.Commands.Moderation.ToSkyra); + if (context.target.id === message.guild.ownerId) { + throw context.args.t(Root.ActionTargetGuildOwner); + } + + if (isUserSelf(context.target.id)) { + throw context.args.t(Root.ActionTargetSkyra); } const member = await message.guild.members.fetch(context.target.id).catch(() => { @@ -141,20 +258,26 @@ export abstract class ModerationCommand extends SkyraCommand { const targetHighestRolePosition = member.roles.highest.position; // Skyra cannot moderate members with higher role position than her: - if (targetHighestRolePosition >= message.guild.members.me!.roles.highest.position) { - throw context.args.t(LanguageKeys.Commands.Moderation.RoleHigherSkyra); + const me = await message.guild.members.fetchMe(); + if (targetHighestRolePosition >= me.roles.highest.position) { + throw context.args.t(Root.ActionTargetHigherHierarchySkyra); } // A member who isn't a server owner is not allowed to moderate somebody with higher role than them: if (!isGuildOwner(message.member) && targetHighestRolePosition >= message.member.roles.highest.position) { - throw context.args.t(LanguageKeys.Commands.Moderation.RoleHigher); + throw context.args.t(Root.ActionTargetHigherHierarchyAuthor); } } return member; } - protected async getTargetDM(message: GuildMessage, args: Args, target: User): Promise { + protected async getActionData( + message: GuildMessage, + args: Args, + target: User, + context?: GetContextType + ): Promise>> { const [nameDisplay, enabledDM] = await readSettings(message.guild, [ GuildSettings.Messages.ModeratorNameDisplay, GuildSettings.Messages.ModerationDM @@ -162,29 +285,58 @@ export abstract class ModerationCommand extends SkyraCommand { return { moderator: args.getFlags('no-author') ? null : args.getFlags('authored') || nameDisplay ? message.author : null, - send: + sendDirectMessage: // --no-dm disables !args.getFlags('no-dm') && // --dm and enabledDM enable (args.getFlags('dm') || enabledDM) && // user settings - (await this.container.db.fetchModerationDirectMessageEnabled(target.id)) + (await this.container.db.fetchModerationDirectMessageEnabled(target.id)), + context }; } - protected async resolveOverloads(args: ModerationCommand.Args): Promise { + protected resolveOptions(message: GuildMessage, context: ModerationCommand.HandlerParameters): ModerationAction.PartialOptions { return { - targets: await args.repeat('user', { times: 10 }), - duration: await this.resolveDurationArgument(args), - reason: args.finished ? null : await args.rest('string') + user: context.target, + moderator: message.author, + reason: context.reason, + imageURL: getImage(message), + duration: context.duration }; } - protected abstract handle(message: GuildMessage, context: HandledCommandContext): Promise | ModerationEntity; + /** + * Resolves the overloads for the moderation command. + * + * @param args - The arguments for the moderation command. + * @returns A promise that resolves to a CommandContext object containing the resolved targets, duration, and reason. + */ + protected async resolveParameters(args: ModerationCommand.Args): Promise { + return { + targets: await this.resolveParametersUser(args), + duration: await this.resolveParametersDuration(args), + reason: await this.resolveParametersReason(args) + }; + } - private async resolveDurationArgument(args: ModerationCommand.Args) { + /** + * Resolves the value for {@linkcode Parameters.targets}. + * + * @param args - The arguments for the moderation command. + */ + protected resolveParametersUser(args: ModerationCommand.Args): Promise { + return args.repeat('user', { times: 10 }); + } + + /** + * Resolves the value for {@linkcode Parameters.duration}. + * + * @param args - The arguments for the moderation command. + */ + protected async resolveParametersDuration(args: ModerationCommand.Args) { if (args.finished) return null; - if (!this.optionalDuration) return null; + if (!this.supportsSchedule) return null; const result = await args.pickResult('timespan', { minimum: 0, maximum: years(5) }); return result.match({ @@ -195,34 +347,45 @@ export abstract class ModerationCommand extends SkyraCommand { } }); } + + /** + * Resolves the value for {@linkcode Parameters.reason}. + * + * @param args - The arguments for the moderation command. + */ + protected resolveParametersReason(args: ModerationCommand.Args): Promise { + return args.finished ? Promise.resolve(null) : args.rest('string'); + } } export namespace ModerationCommand { /** * The ModerationCommand Options */ - export interface Options extends SkyraCommand.Options { + export interface Options extends SkyraCommand.Options { + type: Type; + isUndoAction?: boolean; + actionStatusKey?: TypedT; requiredMember?: boolean; - optionalDuration?: boolean; } export type Args = SkyraCommand.Args; export type Context = SkyraCommand.LoaderContext; export type RunContext = SkyraCommand.RunContext; -} -export interface CommandContext { - targets: User[]; - duration: number | null; - reason: string | null; -} + export interface Parameters { + targets: User[]; + duration: number | null; + reason: string | null; + } -export interface HandledCommandContext extends Pick { - args: ModerationCommand.Args; - target: User; - preHandled: T; -} + export interface HandlerParameters extends Omit { + args: Args; + target: User; + preHandled: ValueType; + } -export interface PostHandledCommandContext extends CommandContext { - preHandled: T; + export interface PostHandleParameters extends Parameters { + preHandled: ValueType; + } } diff --git a/src/lib/moderation/structures/ModerationListener.ts b/src/lib/moderation/structures/ModerationListener.ts index 03fdd63abc0..29763ac8c1a 100644 --- a/src/lib/moderation/structures/ModerationListener.ts +++ b/src/lib/moderation/structures/ModerationListener.ts @@ -1,8 +1,9 @@ import { readSettings, type GuildSettingsOfType } from '#lib/database'; +import { ModerationActions } from '#lib/moderation/actions/index'; import type { HardPunishment } from '#lib/moderation/structures/ModerationMessageListener'; import { SelfModeratorBitField, SelfModeratorHardActionFlags } from '#lib/moderation/structures/SelfModeratorBitField'; import { seconds } from '#utils/common'; -import { getModeration, getSecurity } from '#utils/functions'; +import { getModeration } from '#utils/functions'; import type { EmbedBuilder } from '@discordjs/builders'; import { Listener, type Awaitable } from '@sapphire/framework'; import type { Guild } from 'discord.js'; @@ -41,46 +42,29 @@ export abstract class ModerationListener exten protected async onWarning(guild: Guild, userId: string) { const duration = await readSettings(guild, this.hardPunishmentPath.actionDuration); await this.createActionAndSend(guild, () => - getSecurity(guild).actions.warning({ - userId, - moderatorId: process.env.CLIENT_ID, - reason: '[Auto-Moderation] Threshold Reached.', - duration - }) + ModerationActions.warning.apply(guild, { user: userId, reason: '[Auto-Moderation] Threshold Reached.', duration }) ); } protected async onKick(guild: Guild, userId: string) { await this.createActionAndSend(guild, () => - getSecurity(guild).actions.kick({ - userId, - moderatorId: process.env.CLIENT_ID, - reason: '[Auto-Moderation] Threshold Reached.' - }) + ModerationActions.kick.apply(guild, { user: userId, reason: '[Auto-Moderation] Threshold Reached.' }) ); } protected async onMute(guild: Guild, userId: string) { const duration = await readSettings(guild, this.hardPunishmentPath.actionDuration); await this.createActionAndSend(guild, () => - getSecurity(guild).actions.mute({ - userId, - moderatorId: process.env.CLIENT_ID, - reason: '[Auto-Moderation] Threshold Reached.', - duration - }) + ModerationActions.mute.apply(guild, { user: userId, reason: '[Auto-Moderation] Threshold Reached.', duration }) ); } protected async onSoftBan(guild: Guild, userId: string) { await this.createActionAndSend(guild, () => - getSecurity(guild).actions.softBan( - { - userId, - moderatorId: process.env.CLIENT_ID, - reason: '[Auto-Moderation] Threshold Reached.' - }, - seconds.fromMinutes(5) + ModerationActions.softban.apply( + guild, + { user: userId, reason: '[Auto-Moderation] Threshold Reached.' }, + { context: seconds.fromMinutes(5) } ) ); } @@ -89,12 +73,7 @@ export abstract class ModerationListener exten const duration = await readSettings(guild, this.hardPunishmentPath.actionDuration); await this.createActionAndSend(guild, () => - getSecurity(guild).actions.ban({ - userId, - moderatorId: process.env.CLIENT_ID, - reason: '[Auto-Moderation] Threshold Reached.', - duration - }) + ModerationActions.ban.apply(guild, { user: userId, reason: '[Auto-Moderation] Threshold Reached.', duration }) ); } diff --git a/src/lib/moderation/structures/ModerationMessageListener.ts b/src/lib/moderation/structures/ModerationMessageListener.ts index e40394bea5e..2a7e4b832f8 100644 --- a/src/lib/moderation/structures/ModerationMessageListener.ts +++ b/src/lib/moderation/structures/ModerationMessageListener.ts @@ -1,9 +1,10 @@ import { GuildEntity, GuildSettings, readSettings, type AdderKey, type GuildSettingsOfType } from '#lib/database'; import type { AdderError } from '#lib/database/utils/Adder'; +import { ModerationActions } from '#lib/moderation/actions/index'; import { SelfModeratorBitField, SelfModeratorHardActionFlags } from '#lib/moderation/structures/SelfModeratorBitField'; import { Events, type GuildMessage, type TypedFT, type TypedT } from '#lib/types'; import { floatPromise, seconds } from '#utils/common'; -import { getModeration, getSecurity, isModerator } from '#utils/functions'; +import { getModeration, isModerator } from '#utils/functions'; import { EmbedBuilder } from '@discordjs/builders'; import { canSendMessages, type GuildTextBasedChannelTypes } from '@sapphire/discord.js-utilities'; import { Listener } from '@sapphire/framework'; @@ -105,57 +106,35 @@ export abstract class ModerationMessageListener extends Listener { protected async onWarning(message: GuildMessage, t: TFunction, points: number, maximum: number, duration: number | null) { await this.createActionAndSend(message, () => - getSecurity(message.guild).actions.warning({ - userId: message.author.id, - moderatorId: process.env.CLIENT_ID, - reason: maximum === 0 ? t(this.reasonLanguageKey) : t(this.reasonLanguageKeyWithMaximum, { amount: points, maximum }), - duration - }) + ModerationActions.warning.apply(message.guild, { user: message.author, reason: this.#getReason(t, points, maximum), duration }) ); } protected async onKick(message: GuildMessage, t: TFunction, points: number, maximum: number) { await this.createActionAndSend(message, () => - getSecurity(message.guild).actions.kick({ - userId: message.author.id, - moderatorId: process.env.CLIENT_ID, - reason: maximum === 0 ? t(this.reasonLanguageKey) : t(this.reasonLanguageKeyWithMaximum, { amount: points, maximum }) - }) + ModerationActions.kick.apply(message.guild, { user: message.author, reason: this.#getReason(t, points, maximum) }) ); } protected async onMute(message: GuildMessage, t: TFunction, points: number, maximum: number, duration: number | null) { await this.createActionAndSend(message, () => - getSecurity(message.guild).actions.mute({ - userId: message.author.id, - moderatorId: process.env.CLIENT_ID, - reason: maximum === 0 ? t(this.reasonLanguageKey) : t(this.reasonLanguageKeyWithMaximum, { amount: points, maximum }), - duration - }) + ModerationActions.mute.apply(message.guild, { user: message.author, reason: this.#getReason(t, points, maximum), duration }) ); } protected async onSoftBan(message: GuildMessage, t: TFunction, points: number, maximum: number) { await this.createActionAndSend(message, () => - getSecurity(message.guild).actions.softBan( - { - userId: message.author.id, - moderatorId: process.env.CLIENT_ID, - reason: maximum === 0 ? t(this.reasonLanguageKey) : t(this.reasonLanguageKeyWithMaximum, { amount: points, maximum }) - }, - seconds.fromMinutes(5) + ModerationActions.softban.apply( + message.guild, + { user: message.author, reason: this.#getReason(t, points, maximum) }, + { context: seconds.fromMinutes(5) } ) ); } protected async onBan(message: GuildMessage, t: TFunction, points: number, maximum: number, duration: number | null) { await this.createActionAndSend(message, () => - getSecurity(message.guild).actions.ban({ - userId: message.author.id, - moderatorId: process.env.CLIENT_ID, - reason: maximum === 0 ? t(this.reasonLanguageKey) : t(this.reasonLanguageKeyWithMaximum, { amount: points, maximum }), - duration - }) + ModerationActions.ban.apply(message.guild, { user: message.author, reason: this.#getReason(t, points, maximum), duration }) ); } @@ -207,6 +186,10 @@ export abstract class ModerationMessageListener extends Listener { const { roles } = member; return !ignoredRoles.some((id) => roles.cache.has(id)); } + + #getReason(t: TFunction, points: number, maximum: number) { + return maximum === 0 ? t(this.reasonLanguageKey) : t(this.reasonLanguageKeyWithMaximum, { amount: points, maximum }); + } } export interface HardPunishment { diff --git a/src/lib/moderation/structures/ModerationTask.ts b/src/lib/moderation/structures/ModerationTask.ts index e8a40b1d409..8581b643ffb 100644 --- a/src/lib/moderation/structures/ModerationTask.ts +++ b/src/lib/moderation/structures/ModerationTask.ts @@ -1,13 +1,15 @@ -import { GuildSettings, readSettings, ResponseType, Task, type PartialResponseValue } from '#lib/database'; +import { GuildSettings, ResponseType, Task, readSettings, type PartialResponseValue } from '#lib/database'; +import type { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; +import { getModeration } from '#utils/functions'; import type { SchemaKeys } from '#utils/moderationConstants'; -import type { ModerationActionsSendOptions } from '#utils/Security/ModerationActions'; -import type { Guild, User } from 'discord.js'; +import { isNullish } from '@sapphire/utilities'; +import type { Guild, Snowflake } from 'discord.js'; export abstract class ModerationTask extends Task { public async run(data: ModerationData): Promise { const guild = this.container.client.guilds.cache.get(data.guildID); // If the guild is not available, cancel the task. - if (typeof guild === 'undefined') return { type: ResponseType.Ignore }; + if (isNullish(guild)) return { type: ResponseType.Ignore }; // If the guild is not available, re-schedule the task by creating // another with the same data but happening 20 seconds later. @@ -20,15 +22,23 @@ export abstract class ModerationTask extends Task { /* noop */ } + // Mark the moderation entry as complete. + await getModeration(guild).complete(data.caseID); + return { type: ResponseType.Finished }; } - protected async getTargetDM(guild: Guild, target: User): Promise { + protected async getActionData( + guild: Guild, + targetId: Snowflake, + context?: ContextType + ): Promise> { return { moderator: null, - send: + sendDirectMessage: (await readSettings(guild, GuildSettings.Messages.ModerationDM)) && - (await this.container.db.fetchModerationDirectMessageEnabled(target.id)) + (await this.container.db.fetchModerationDirectMessageEnabled(targetId)), + context }; } diff --git a/src/lib/moderation/structures/SetUpModerationCommand.ts b/src/lib/moderation/structures/SetUpModerationCommand.ts index 47dd9c314cc..cd99e86e063 100644 --- a/src/lib/moderation/structures/SetUpModerationCommand.ts +++ b/src/lib/moderation/structures/SetUpModerationCommand.ts @@ -1,22 +1,23 @@ -import { readSettings, writeSettings, type GuildSettingsOfType } from '#lib/database'; +import { readSettings, writeSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; +import type { RoleTypeVariation } from '#lib/moderation'; import { ModerationCommand } from '#lib/moderation/structures/ModerationCommand'; import type { GuildMessage } from '#lib/types'; -import type { ModerationSetupRestriction } from '#utils/Security/ModerationActions'; -import { getSecurity, isAdmin, promptConfirmation, promptForMessage } from '#utils/functions'; +import { isAdmin, promptConfirmation, promptForMessage } from '#utils/functions'; import type { Argument } from '@sapphire/framework'; import { send } from '@sapphire/plugin-editable-commands'; -import type { Nullish } from '@sapphire/utilities'; -import type { Role } from 'discord.js'; +import { PermissionFlagsBits, type Role } from 'discord.js'; -export abstract class SetUpModerationCommand extends ModerationCommand { - public readonly roleKey: GuildSettingsOfType; - public readonly setUpKey: ModerationSetupRestriction; - - public constructor(context: ModerationCommand.Context, options: SetUpModerationCommand.Options) { - super(context, options); - this.roleKey = options.roleKey; - this.setUpKey = options.setUpKey; +export abstract class SetUpModerationCommand extends ModerationCommand { + public constructor(context: ModerationCommand.Context, options: SetUpModerationCommand.Options) { + super(context, { + requiredClientPermissions: [PermissionFlagsBits.ManageRoles], + requiredMember: true, + actionStatusKey: options.isUndoAction + ? LanguageKeys.Moderation.ActionIsNotActiveRestrictionRole + : LanguageKeys.Moderation.ActionIsActiveRestrictionRole, + ...options + }); } private get role() { @@ -32,12 +33,12 @@ export abstract class SetUpModerationCommand extends ModerationCommand { return super.messageRun(message, args, context); } - public async inhibit(message: GuildMessage, args: ModerationCommand.Args, context: ModerationCommand.RunContext) { + protected async inhibit(message: GuildMessage, args: ModerationCommand.Args, context: ModerationCommand.RunContext) { // If the command messageRun is not this one (potentially help command) or the guild is null, return with no error. - const [id, t] = await readSettings(message.guild, (settings) => [settings[this.roleKey], settings.getLanguage()]); + const [roleId, t] = await readSettings(message.guild, (settings) => [settings[this.action.roleKey], settings.getLanguage()]); // Verify for role existence. - const role = (id && message.guild.roles.cache.get(id)) ?? null; + const role = (roleId && message.guild.roles.cache.get(roleId)) ?? null; if (role) return undefined; // If there @@ -47,9 +48,9 @@ export abstract class SetUpModerationCommand extends ModerationCommand { if (await promptConfirmation(message, t(LanguageKeys.Commands.Moderation.ActionSharedRoleSetupExisting))) { const role = (await this.askForRole(message, args, context)).unwrapRaw(); - await writeSettings(message.guild, [[this.roleKey, role.id]]); + await writeSettings(message.guild, [[this.action.roleKey, role.id]]); } else if (await promptConfirmation(message, t(LanguageKeys.Commands.Moderation.ActionSharedRoleSetupNew))) { - await getSecurity(message.guild).actions.restrictionSetup(message, this.setUpKey); + await this.action.setup(message); const content = t(LanguageKeys.Commands.Moderation.Success); await send(message, content); @@ -70,15 +71,13 @@ export abstract class SetUpModerationCommand extends ModerationCommand { } export namespace SetUpModerationCommand { - /** - * The ModerationCommand Options - */ - export interface Options extends ModerationCommand.Options { - roleKey: GuildSettingsOfType; - setUpKey: ModerationSetupRestriction; - } + export type Options = ModerationCommand.Options; export type Args = ModerationCommand.Args; export type Context = ModerationCommand.Context; export type RunContext = ModerationCommand.RunContext; + + export type Parameters = ModerationCommand.Parameters; + export type HandlerParameters = ModerationCommand.HandlerParameters; + export type PostHandleParameters = ModerationCommand.PostHandleParameters; } diff --git a/src/lib/structures/commands/SkyraCommand.ts b/src/lib/structures/commands/SkyraCommand.ts index 65b276b8ddf..9837985340e 100644 --- a/src/lib/structures/commands/SkyraCommand.ts +++ b/src/lib/structures/commands/SkyraCommand.ts @@ -11,7 +11,7 @@ import { import { PermissionLevels, type TypedT } from '#lib/types'; import { first } from '#utils/common'; import { Command, UserError, type Awaitable, type MessageCommand } from '@sapphire/framework'; -import { type Message, type Snowflake } from 'discord.js'; +import { ChatInputCommandInteraction, type Message, type Snowflake } from 'discord.js'; /** * The base class for all Skyra commands. @@ -43,27 +43,29 @@ export abstract class SkyraCommand extends Command; } @@ -72,4 +74,6 @@ export namespace SkyraCommand { export type Args = SkyraArgs; export type LoaderContext = Command.LoaderContext; export type RunContext = MessageCommand.RunContext; + + export type Interaction = ChatInputCommandInteraction<'cached'>; } diff --git a/src/lib/structures/commands/SkyraSubcommand.ts b/src/lib/structures/commands/SkyraSubcommand.ts index fbb4eacb46c..32fd72df9a6 100644 --- a/src/lib/structures/commands/SkyraSubcommand.ts +++ b/src/lib/structures/commands/SkyraSubcommand.ts @@ -12,7 +12,7 @@ import { PermissionLevels, type TypedT } from '#lib/types'; import { first } from '#utils/common'; import { Command, UserError, type MessageCommand } from '@sapphire/framework'; import { Subcommand } from '@sapphire/plugin-subcommands'; -import type { Message, Snowflake } from 'discord.js'; +import type { ChatInputCommandInteraction, Message, Snowflake } from 'discord.js'; /** * The base class for all Skyra commands with subcommands. @@ -43,27 +43,29 @@ export class SkyraSubcommand extends Subcommand; } @@ -72,4 +74,6 @@ export namespace SkyraSubcommand { export type Args = SkyraArgs; export type LoaderContext = Command.LoaderContext; export type RunContext = MessageCommand.RunContext; + + export type Interaction = ChatInputCommandInteraction<'cached'>; } diff --git a/src/lib/structures/data/SortedCollection.ts b/src/lib/structures/data/SortedCollection.ts new file mode 100644 index 00000000000..81acc6e6e05 --- /dev/null +++ b/src/lib/structures/data/SortedCollection.ts @@ -0,0 +1,228 @@ +import { asc } from '#utils/common'; +import { isFunction } from '@sapphire/utilities'; + +/** + * Represents a collection of key-value pairs that are sorted by the key. + */ +export class SortedCollection implements Map { + /** + * The entries of this collection. + */ + readonly #entries: [K, V][] = []; + + /** + * The comparator function used to sort the collection. + */ + readonly #comparator: (a: K, b: K) => number; + + public constructor(data?: Iterable<[K, V]>, comparator: (a: K, b: K) => number = asc) { + this.#comparator = comparator; + + if (data) { + this.#entries.push(...data); + this.#entries.sort(([aKey], [bKey]) => this.#comparator(aKey, bKey)); + } + } + + /** + * Gets the number of entries in the collection. + */ + public get size() { + return this.#entries.length; + } + + /** + * Sets the value for the specified key in the collection. + * If the key already exists, the value will be updated. + * If the key does not exist, a new entry will be added to the collection. + * + * @param key - The key to set the value for. + * @param value - The value to set. + * @returns The SortedCollection instance. + */ + public set(key: K, value: V) { + let left = 0; + let right = this.#entries.length - 1; + + while (left <= right) { + const mid = (left + right) >> 1; + const midKey = this.#entries[mid][0]; + const cmp = this.#comparator(midKey, key); + if (cmp === 0) { + this.#entries[mid][1] = value; + return this; + } + + if (cmp < 0) left = mid + 1; + else right = mid - 1; + } + + this.#entries.splice(left, 0, [key, value]); + return this; + } + + /** + * Checks if the collection contains a specific key. + * + * @param key - The key to check for. + * @returns `true` if the collection contains the key, `false` otherwise. + */ + public has(key: K): boolean { + return this.indexOf(key) !== -1; + } + + /** + * Retrieves the value associated with the specified key. + * + * @param key - The key to retrieve the value for. + * @returns The value associated with the key, or `undefined` if the key is not found. + */ + public get(key: K): V | undefined { + const index = this.indexOf(key); + return index === -1 ? undefined : this.#entries[index][1]; + } + + /** + * Returns the index of the specified key in the sorted collection. + * If the key is not found, it returns -1. + * + * @param key - The key to search for in the collection. + * @returns The index of the key, or -1 if the key is not found. + */ + public indexOf(key: K) { + let left = 0; + let right = this.#entries.length - 1; + while (left <= right) { + const mid = (left + right) >> 1; + const midKey = this.#entries[mid][0]; + const cmp = this.#comparator(midKey, key); + if (cmp === 0) return mid; + + if (cmp < 0) left = mid + 1; + else right = mid - 1; + } + + return -1; + } + + /** + * Deletes an entry from the collection based on the specified key. + * + * @param key The key of the entry to delete. + * @returns `true` if the entry was successfully deleted, `false` otherwise. + */ + public delete(key: K) { + const index = this.indexOf(key); + if (index === -1) return false; + + this.#entries.splice(index, 1); + return true; + } + + /** + * Clears all entries from the collection. + */ + public clear(): void { + this.#entries.splice(0, this.#entries.length); + } + + /** + * Identical to + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter | Array.filter()}, + * but returns a Collection instead of an Array. + * + * @param fn - The function to test with (should return boolean) + * @param thisArg - Value to use as `this` when executing function + * @example + * ```ts + * collection.filter(user => user.username === 'Bob'); + * ``` + */ + public filter(fn: (value: V, key: K, collection: this) => key is K2): SortedCollection; + public filter(fn: (value: V, key: K, collection: this) => value is V2): SortedCollection; + public filter(fn: (value: V, key: K, collection: this) => unknown): SortedCollection; + public filter(fn: (this: This, value: V, key: K, collection: this) => key is K2, thisArg: This): SortedCollection; + public filter(fn: (this: This, value: V, key: K, collection: this) => value is V2, thisArg: This): SortedCollection; + public filter(fn: (this: This, value: V, key: K, collection: this) => unknown, thisArg: This): SortedCollection; + public filter(fn: (value: V, key: K, collection: this) => unknown, thisArg?: unknown): SortedCollection { + if (!isFunction(fn)) throw new TypeError(`${fn} is not a function`); + if (thisArg !== undefined) fn = fn.bind(thisArg); + + const results = new SortedCollection(undefined, this.#comparator); + for (const entry of this.#entries) { + if (fn(entry[1], entry[0], this)) results.#entries.push(entry); + } + + return results; + } + + /** + * Removes items that satisfy the provided filter function. + * + * @param fn - Function used to test (should return a boolean) + * @param thisArg - Value to use as `this` when executing function + * @returns The number of removed entries + */ + public sweep(fn: (value: V, key: K, collection: this) => unknown): number; + public sweep(fn: (this: T, value: V, key: K, collection: this) => unknown, thisArg: T): number; + public sweep(fn: (value: V, key: K, collection: this) => unknown, thisArg?: unknown): number { + if (!isFunction(fn)) throw new TypeError(`${fn} is not a function`); + if (thisArg !== undefined) fn = fn.bind(thisArg); + + const previousSize = this.size; + let i = 0; + while (i < this.#entries.length) { + const [key, value] = this.#entries[i]; + if (fn(value, key, this)) this.#entries.splice(i, 1); + else i++; + } + + return previousSize - this.size; + } + + public forEach(fn: (value: V, key: K, map: this) => void, thisArg?: unknown): void { + if (!isFunction(fn)) throw new TypeError(`${fn} is not a function`); + if (thisArg !== undefined) fn = fn.bind(thisArg); + + for (const [key, value] of this.#entries) { + fn(value, key, this); + } + } + + /** + * Returns an iterator that contains all the keys in the collection. + */ + public *keys() { + for (const [key] of this.#entries) { + yield key; + } + } + + /** + * Returns an iterator that yields all the values in the collection. + */ + public *values() { + for (const [, value] of this.#entries) { + yield value; + } + } + + /** + * Returns an iterator that yields all the entries in the collection. + */ + public *entries() { + yield* this.#entries; + } + + /** + * Returns an iterator that yields all the entries in the collection. + */ + public *[Symbol.iterator](): IterableIterator<[K, V]> { + yield* this.#entries; + } + + // eslint-disable-next-line @typescript-eslint/class-literal-property-style + public get [Symbol.toStringTag](): string { + return 'SortedCollection'; + } +} diff --git a/src/lib/structures/data/index.ts b/src/lib/structures/data/index.ts new file mode 100644 index 00000000000..f304d6840f6 --- /dev/null +++ b/src/lib/structures/data/index.ts @@ -0,0 +1 @@ +export * from '#lib/structures/data/SortedCollection'; diff --git a/src/lib/structures/managers/ScheduleManager.ts b/src/lib/structures/managers/ScheduleManager.ts index 4be8a6c809e..9e637bfdf61 100644 --- a/src/lib/structures/managers/ScheduleManager.ts +++ b/src/lib/structures/managers/ScheduleManager.ts @@ -35,6 +35,21 @@ export class ScheduleManager { return entry; } + public async reschedule(entityOrId: ScheduleEntity | number, time: Date | number) { + if (typeof entityOrId === 'number') { + entityOrId = this.queue.find((entity) => entity.id === entityOrId)!; + if (!entityOrId) return false; + } + + entityOrId.pause(); + entityOrId.time = new Date(time); + await entityOrId.save(); + + this._remove(entityOrId); + this._insert(entityOrId); + return true; + } + public async remove(entityOrId: ScheduleEntity | number) { if (typeof entityOrId === 'number') { entityOrId = this.queue.find((entity) => entity.id === entityOrId)!; @@ -74,7 +89,7 @@ export class ScheduleManager { } private _remove(entity: ScheduleEntity) { - const index = this.queue.findIndex((entry) => entry === entity); + const index = this.queue.indexOf(entity); if (index !== -1) this.queue.splice(index, 1); } diff --git a/src/lib/types/Utils.ts b/src/lib/types/Utils.ts index 422197fa79f..cca099ab7c0 100644 --- a/src/lib/types/Utils.ts +++ b/src/lib/types/Utils.ts @@ -1,11 +1,14 @@ /* eslint-disable @typescript-eslint/ban-types */ export type TypedT = string & { __type__: TCustom }; +export type GetTypedT> = T extends TypedT ? U : never; export function T(k: string): TypedT { return k as TypedT; } -export type TypedFT = string & { __args__: TArgs; __return__: TReturn }; +export type TypedFT = string & { __args__: TArgs; __return__: TReturn }; +export type GetTypedFTArgs> = T extends TypedFT ? U : never; +export type GetTypedFTReturn> = T extends TypedFT ? U : never; export function FT(k: string): TypedFT { return k as TypedFT; @@ -24,3 +27,7 @@ export interface Difference { previous: T; next: T; } + +export interface Parameter { + parameter: string; +} diff --git a/src/lib/util/Security/GuildSecurity.ts b/src/lib/util/Security/GuildSecurity.ts index c7a6d5e626a..28a873a3f4d 100644 --- a/src/lib/util/Security/GuildSecurity.ts +++ b/src/lib/util/Security/GuildSecurity.ts @@ -1,5 +1,4 @@ import { LockdownManager } from '#lib/structures'; -import { ModerationActions } from '#lib/util/Security/ModerationActions'; import type { Guild } from 'discord.js'; /** @@ -11,11 +10,6 @@ export class GuildSecurity { */ public guild: Guild; - /** - * The moderation actions - */ - public actions: ModerationActions; - /** * The lockdowns map */ @@ -23,6 +17,5 @@ export class GuildSecurity { public constructor(guild: Guild) { this.guild = guild; - this.actions = new ModerationActions(this.guild); } } diff --git a/src/lib/util/Security/ModerationActions.ts b/src/lib/util/Security/ModerationActions.ts deleted file mode 100644 index ffe96d01ab2..00000000000 --- a/src/lib/util/Security/ModerationActions.ts +++ /dev/null @@ -1,994 +0,0 @@ -import type { GuildSettingsOfType, ModerationEntity } from '#lib/database/entities'; -import { GuildSettings } from '#lib/database/keys'; -import { readSettings, writeSettings } from '#lib/database/settings'; -import { api } from '#lib/discord/Api'; -import { LanguageKeys } from '#lib/i18n/languageKeys'; -import type { ModerationManagerCreateData } from '#lib/moderation'; -import { resolveOnErrorCodes } from '#utils/common'; -import { getModeration, getStickyRoles, promptConfirmation } from '#utils/functions'; -import { TypeMetadata, TypeVariation } from '#utils/moderationConstants'; -import { getFullEmbedAuthor } from '#utils/util'; -import { EmbedBuilder } from '@discordjs/builders'; -import { isCategoryChannel, isNewsChannel, isStageChannel, isTextChannel, isVoiceChannel } from '@sapphire/discord.js-utilities'; -import { UserError, container } from '@sapphire/framework'; -import type { TFunction } from '@sapphire/plugin-i18next'; -import { fetchT, resolveKey } from '@sapphire/plugin-i18next'; -import { isNullish, isNullishOrEmpty, isNullishOrZero, type Nullish } from '@sapphire/utilities'; -import { - DiscordAPIError, - PermissionFlagsBits, - RESTJSONErrorCodes, - type Guild, - type GuildChannel, - type GuildMember, - type Message, - type PermissionOverwriteOptions, - type Role, - type RoleData, - type User -} from 'discord.js'; - -export const enum ModerationSetupRestriction { - All = 'rolesMuted', - Reaction = 'rolesRestrictedReaction', - Embed = 'rolesRestrictedEmbed', - Emoji = 'rolesRestrictedEmoji', - Attachment = 'rolesRestrictedAttachment', - Voice = 'rolesRestrictedVoice' -} - -const enum RoleDataKey { - Muted, - Reaction, - Embed, - Emoji, - Attachment, - Voice -} - -const kRoleDataOptions = new Map([ - [ - RoleDataKey.Muted, - { - color: 0x000000, - hoist: false, - mentionable: false, - name: 'Muted', - permissions: [] - } - ], - [ - RoleDataKey.Attachment, - { - color: 0x000000, - hoist: false, - mentionable: false, - name: 'Restricted Attachment', - permissions: [] - } - ], - [ - RoleDataKey.Embed, - { - color: 0x000000, - hoist: false, - mentionable: false, - name: 'Restricted Embed', - permissions: [] - } - ], - [ - RoleDataKey.Emoji, - { - color: 0x000000, - hoist: false, - mentionable: false, - name: 'Restricted Emoji', - permissions: [] - } - ], - [ - RoleDataKey.Reaction, - { - color: 0x000000, - hoist: false, - mentionable: false, - name: 'Restricted Reaction', - permissions: [] - } - ], - [ - RoleDataKey.Voice, - { - color: 0x000000, - hoist: false, - mentionable: false, - name: 'Restricted Voice', - permissions: [] - } - ] -]); - -const kRoleChannelOverwriteOptions = new Map([ - [ - RoleDataKey.Muted, - { - category: { - options: { SendMessages: false, AddReactions: false, Connect: false, CreatePublicThreads: false, CreatePrivateThreads: false }, - permissions: - PermissionFlagsBits.SendMessages | - PermissionFlagsBits.AddReactions | - PermissionFlagsBits.Connect | - PermissionFlagsBits.CreatePublicThreads | - PermissionFlagsBits.CreatePrivateThreads - }, - text: { - options: { SendMessages: false, AddReactions: false }, - permissions: PermissionFlagsBits.SendMessages | PermissionFlagsBits.AddReactions - }, - voice: { - options: { Connect: false }, - permissions: PermissionFlagsBits.Connect - } - } - ], - [ - RoleDataKey.Attachment, - { - category: { - options: { AttachFiles: false }, - permissions: PermissionFlagsBits.AttachFiles - }, - text: { - options: { AttachFiles: false }, - permissions: PermissionFlagsBits.AttachFiles - }, - voice: null - } - ], - [ - RoleDataKey.Embed, - { - category: { - options: { EmbedLinks: false }, - permissions: PermissionFlagsBits.EmbedLinks - }, - text: { - options: { EmbedLinks: false }, - permissions: PermissionFlagsBits.EmbedLinks - }, - voice: null - } - ], - [ - RoleDataKey.Emoji, - { - category: { - options: { UseExternalEmojis: false }, - permissions: PermissionFlagsBits.UseExternalEmojis - }, - text: { - options: { UseExternalEmojis: false }, - permissions: PermissionFlagsBits.UseExternalEmojis - }, - voice: null - } - ], - [ - RoleDataKey.Reaction, - { - category: { - options: { AddReactions: false }, - permissions: PermissionFlagsBits.AddReactions - }, - text: { - options: { AddReactions: false }, - permissions: PermissionFlagsBits.AddReactions - }, - voice: null - } - ], - [ - RoleDataKey.Voice, - { - category: { - options: { Connect: false }, - permissions: PermissionFlagsBits.Connect - }, - text: null, - voice: { - options: { Connect: false }, - permissions: PermissionFlagsBits.Connect - } - } - ] -]); - -export interface ModerationAction { - addRole: string; - mute: string; - ban: string; - kick: string; - softban: string; - vkick: string; - vmute: string; - restrictedReact: string; - restrictedEmbed: string; - restrictedAttachment: string; - restrictedVoice: string; - setNickname: string; - removeRole: string; -} - -export class ModerationActions { - public guild: Guild; - - public constructor(guild: Guild) { - this.guild = guild; - } - - private get manageableChannelCount() { - return this.guild.channels.cache.reduce((acc, channel) => (channel.manageable ? acc + 1 : acc), 0); - } - - public async warning(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.Warning); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - return (await moderationLog.create())!; - } - - public async unWarning(rawOptions: ModerationActionOptions, caseId: number, sendOptions?: ModerationActionsSendOptions) { - const oldModerationLog = await getModeration(this.guild).fetch(caseId); - if (oldModerationLog === null || oldModerationLog.type !== TypeVariation.Warning) - throw await resolveKey(this.guild, LanguageKeys.Commands.Moderation.GuildWarnNotFound); - - await oldModerationLog.invalidate(); - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.Warning, TypeMetadata.Appeal); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - return (await moderationLog.create())!; - } - - public async setNickname(rawOptions: ModerationActionOptions, nickname: string, sendOptions?: ModerationActionsSendOptions) { - const oldName = this.guild.members.cache.get(rawOptions.userId)?.nickname || ''; - const options = ModerationActions.fillOptions({ ...rawOptions, extraData: { oldName } }, TypeVariation.SetNickname); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - const reason = await (moderationLog.reason - ? resolveKey( - this.guild, - nickname ? LanguageKeys.Commands.Moderation.ActionSetNicknameSet : LanguageKeys.Commands.Moderation.ActionSetNicknameRemoved, - { reason: moderationLog.reason } - ) - : resolveKey( - this.guild, - nickname - ? LanguageKeys.Commands.Moderation.ActionSetNicknameNoReasonSet - : LanguageKeys.Commands.Moderation.ActionSetNicknameNoReasonRemoved - )); - await api().guilds.editMember(this.guild.id, rawOptions.userId, { nick: nickname }, { reason }); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.SetNickname, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async unSetNickname(rawOptions: ModerationActionOptions, nickname: string, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.SetNickname, TypeMetadata.Appeal); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - await api().guilds.editMember(this.guild.id, rawOptions.userId, { nick: nickname }, { reason: rawOptions.reason ?? undefined }); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.SetNickname, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async addRole(rawOptions: ModerationActionOptions, role: Role, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions({ ...rawOptions, extraData: { role: role.id } }, TypeVariation.AddRole); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - await api().guilds.addRoleToMember(this.guild.id, rawOptions.userId, role.id, { - reason: await this.getReason('addRole', moderationLog.reason) - }); - - await this.cancelLastLogTaskFromUser( - options.userId, - TypeVariation.AddRole, - TypeMetadata.None, - (log) => (log.extraData as { role?: string })?.role === role.id - ); - return (await moderationLog.create())!; - } - - public async unAddRole(rawOptions: ModerationActionOptions, role: Role, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.AddRole, TypeMetadata.Appeal); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - await api().guilds.removeRoleFromMember(this.guild.id, rawOptions.userId, role.id, { reason: rawOptions.reason! }); - - await this.cancelLastLogTaskFromUser( - options.userId, - TypeVariation.AddRole, - TypeMetadata.None, - (log) => (log.extraData as { role?: string })?.role === role.id - ); - return (await moderationLog.create())!; - } - - public async removeRole(rawOptions: ModerationActionOptions, role: Role, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions({ ...rawOptions, extraData: { role: role.id } }, TypeVariation.RemoveRole); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - await api().guilds.removeRoleFromMember(this.guild.id, rawOptions.userId, role.id, { - reason: await this.getReason('removeRole', moderationLog.reason) - }); - - await this.cancelLastLogTaskFromUser( - options.userId, - TypeVariation.RemoveRole, - TypeMetadata.None, - (log) => (log.extraData as { role?: string })?.role === role.id - ); - return (await moderationLog.create())!; - } - - public async unRemoveRole(rawOptions: ModerationActionOptions, role: Role, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.RemoveRole, TypeMetadata.Appeal); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - await api().guilds.addRoleToMember(this.guild.id, rawOptions.userId, role.id, { reason: rawOptions.reason ?? undefined }); - - await this.cancelLastLogTaskFromUser( - options.userId, - TypeVariation.RemoveRole, - TypeMetadata.None, - (log) => (log.extraData as { role?: string })?.role === role.id - ); - return (await moderationLog.create())!; - } - - public async mute(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - await this.addStickyMute(rawOptions.userId); - const extraData = await this.muteUser(rawOptions); - const options = ModerationActions.fillOptions({ ...rawOptions, extraData }, TypeVariation.Mute); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.Mute, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async unMute(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.Mute, TypeMetadata.Appeal); - await this.removeStickyMute(options.userId); - const oldModerationLog = await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.Mute, TypeMetadata.None); - if (typeof oldModerationLog === 'undefined') { - throw await resolveKey(this.guild, LanguageKeys.Commands.Moderation.MuteNotExists); - } - - // If Skyra does not have permissions to manage permissions, abort. - if (!(await this.fetchMe()).permissions.has(PermissionFlagsBits.ManageRoles)) { - throw await resolveKey(this.guild, LanguageKeys.Commands.Moderation.MuteCannotManageRoles); - } - - await this.unmuteUser(options, oldModerationLog); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - return (await moderationLog.create())!; - } - - public async kick(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.Kick); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - await api().guilds.removeMember(this.guild.id, options.userId, { reason: await this.getReason('kick', moderationLog.reason) }); - return (await moderationLog.create())!; - } - - public async softBan(rawOptions: ModerationActionOptions, seconds?: number, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.SoftBan); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - const t = await fetchT(this.guild); - await api().guilds.banUser( - this.guild.id, - options.userId, - { delete_message_seconds: seconds ?? 0 }, - { - reason: moderationLog.reason - ? t(LanguageKeys.Commands.Moderation.ActionSoftBanReason, { reason: moderationLog.reason! }) - : t(LanguageKeys.Commands.Moderation.ActionSoftBanNoReason) - } - ); - await api().guilds.unbanUser(this.guild.id, options.userId, { - reason: moderationLog.reason - ? t(LanguageKeys.Commands.Moderation.ActionUnSoftBanReason, { reason: moderationLog.reason! }) - : t(LanguageKeys.Commands.Moderation.ActionUnSoftBanNoReason) - }); - return (await moderationLog.create())!; - } - - public async ban(rawOptions: ModerationActionOptions, seconds?: number, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.Ban); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - await api().guilds.banUser( - this.guild.id, - options.userId, - { delete_message_seconds: seconds ?? 0 }, - { reason: await this.getReason('ban', moderationLog.reason) } - ); - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.Ban, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async unBan(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.Ban, TypeMetadata.Appeal); - const moderationLog = getModeration(this.guild).create(options); - await api().guilds.unbanUser(this.guild.id, options.userId, { reason: await this.getReason('ban', moderationLog.reason, true) }); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.Ban, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async voiceMute(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.VoiceMute); - const moderationLog = getModeration(this.guild).create(options); - await api().guilds.editMember(this.guild.id, options.userId, { mute: true }, { reason: await this.getReason('vmute', moderationLog.reason) }); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.VoiceMute, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async unVoiceMute(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.VoiceMute, TypeMetadata.Appeal); - const moderationLog = getModeration(this.guild).create(options); - await api().guilds.editMember( - this.guild.id, - options.userId, - { mute: false }, - { reason: await this.getReason('vmute', moderationLog.reason, true) } - ); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.VoiceMute, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async voiceKick(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.VoiceKick); - const moderationLog = getModeration(this.guild).create(options); - await api().guilds.editMember( - this.guild.id, - options.userId, - { channel_id: null }, - { reason: await this.getReason('vkick', moderationLog.reason) } - ); - await this.sendDM(moderationLog, sendOptions); - return (await moderationLog.create())!; - } - - public async restrictAttachment(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - await this.addStickyRestriction(rawOptions.userId, GuildSettings.Roles.RestrictedAttachment); - await this.addRestrictionRole(rawOptions.userId, GuildSettings.Roles.RestrictedAttachment); - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.RestrictedAttachment); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.RestrictedAttachment, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async unRestrictAttachment(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - await this.removeStickyRestriction(rawOptions.userId, GuildSettings.Roles.RestrictedAttachment); - await this.removeRestrictionRole(rawOptions.userId, GuildSettings.Roles.RestrictedAttachment); - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.RestrictedAttachment, TypeMetadata.Appeal); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.RestrictedAttachment, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async restrictReaction(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - await this.addStickyRestriction(rawOptions.userId, GuildSettings.Roles.RestrictedReaction); - await this.addRestrictionRole(rawOptions.userId, GuildSettings.Roles.RestrictedReaction); - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.RestrictedReaction); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.RestrictedReaction, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async unRestrictReaction(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - await this.removeStickyRestriction(rawOptions.userId, GuildSettings.Roles.RestrictedReaction); - await this.removeRestrictionRole(rawOptions.userId, GuildSettings.Roles.RestrictedReaction); - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.RestrictedReaction, TypeMetadata.Appeal); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.RestrictedReaction, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async restrictEmbed(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - await this.addStickyRestriction(rawOptions.userId, GuildSettings.Roles.RestrictedEmbed); - await this.addRestrictionRole(rawOptions.userId, GuildSettings.Roles.RestrictedEmbed); - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.RestrictedEmbed); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.RestrictedEmbed, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async unRestrictEmbed(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - await this.removeStickyRestriction(rawOptions.userId, GuildSettings.Roles.RestrictedEmbed); - await this.removeRestrictionRole(rawOptions.userId, GuildSettings.Roles.RestrictedEmbed); - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.RestrictedEmbed, TypeMetadata.Appeal); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.RestrictedEmbed, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async restrictEmoji(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - await this.addStickyRestriction(rawOptions.userId, GuildSettings.Roles.RestrictedEmoji); - await this.addRestrictionRole(rawOptions.userId, GuildSettings.Roles.RestrictedEmoji); - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.RestrictedEmoji); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.RestrictedEmoji, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async unRestrictEmoji(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - await this.removeStickyRestriction(rawOptions.userId, GuildSettings.Roles.RestrictedEmoji); - await this.removeRestrictionRole(rawOptions.userId, GuildSettings.Roles.RestrictedEmoji); - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.RestrictedEmoji, TypeMetadata.Appeal); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.RestrictedEmoji, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async restrictVoice(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - await this.addStickyRestriction(rawOptions.userId, GuildSettings.Roles.RestrictedVoice); - await this.addRestrictionRole(rawOptions.userId, GuildSettings.Roles.RestrictedVoice); - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.RestrictedVoice); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.RestrictedVoice, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async unRestrictVoice(rawOptions: ModerationActionOptions, sendOptions?: ModerationActionsSendOptions) { - await this.removeStickyRestriction(rawOptions.userId, GuildSettings.Roles.RestrictedVoice); - await this.removeRestrictionRole(rawOptions.userId, GuildSettings.Roles.RestrictedVoice); - const options = ModerationActions.fillOptions(rawOptions, TypeVariation.RestrictedVoice, TypeMetadata.Appeal); - const moderationLog = getModeration(this.guild).create(options); - await this.sendDM(moderationLog, sendOptions); - - await this.cancelLastLogTaskFromUser(options.userId, TypeVariation.RestrictedVoice, TypeMetadata.None); - return (await moderationLog.create())!; - } - - public async muteSetup(message: Message) { - const [roleId] = await readSettings(this.guild, (settings) => [settings[GuildSettings.Roles.Muted]]); - if (roleId && this.guild.roles.cache.has(roleId)) throw new UserError({ identifier: LanguageKeys.Commands.Moderation.ActionSetupMuteExists }); - if (this.guild.roles.cache.size >= 250) throw new UserError({ identifier: LanguageKeys.Commands.Moderation.ActionSetupTooManyRoles }); - - // Set up the shared role setup - return this.sharedRoleSetup(message, RoleDataKey.Muted, GuildSettings.Roles.Muted); - } - - public async restrictionSetup(message: Message, path: ModerationSetupRestriction) { - const [roleId] = await readSettings(this.guild, (settings) => [settings[path]]); - if (!isNullish(roleId) && this.guild.roles.cache.has(roleId)) { - throw new UserError({ identifier: LanguageKeys.Commands.Moderation.ActionSetupRestrictionExists }); - } - if (this.guild.roles.cache.size >= 250) throw new UserError({ identifier: LanguageKeys.Commands.Moderation.ActionSetupTooManyRoles }); - - // Set up the shared role setup - return this.sharedRoleSetup(message, ModerationActions.getRoleDataKeyFromSchemaKey(path), path); - } - - public async userIsBanned(user: User) { - try { - await api().guilds.getMemberBan(this.guild.id, user.id); - return true; - } catch (error) { - if (!(error instanceof DiscordAPIError)) throw await resolveKey(this.guild, LanguageKeys.System.FetchBansFail); - if (error.code === RESTJSONErrorCodes.UnknownBan) return false; - throw error; - } - } - - public async userIsMuted(user: User) { - const roleId = await readSettings(this.guild, GuildSettings.Roles.Muted); - if (isNullish(roleId)) return false; - return getStickyRoles(this.guild).has(user.id, roleId); - } - - public async userIsVoiceMuted(user: User) { - const member = await resolveOnErrorCodes(this.guild.members.fetch(user.id), RESTJSONErrorCodes.UnknownUser); - return member?.voice.serverMute ?? false; - } - - private async sharedRoleSetup(message: Message, key: RoleDataKey, path: GuildSettingsOfType) { - const roleData = kRoleDataOptions.get(key)!; - const role = await this.guild.roles.create({ - ...roleData, - reason: `[Role Setup] Authorized by ${message.author.username} (${message.author.id}).` - }); - const t = await writeSettings(this.guild, (settings) => { - Reflect.set(settings, path, role.id); - return settings.getLanguage(); - }); - - if ( - await promptConfirmation( - message, - t(LanguageKeys.Commands.Moderation.ActionSharedRoleSetupAsk, { - role: role.name, - channels: this.manageableChannelCount, - permissions: this.displayPermissions(t, key).map((permission) => `\`${permission}\``) - }) - ) - ) { - await this.updatePermissionsForCategoryChannels(role, key); - await this.updatePermissionsForTextOrVoiceChannels(role, key); - } - } - - private displayPermissions(t: TFunction, key: RoleDataKey) { - const options = kRoleChannelOverwriteOptions.get(key)!; - const output: string[] = []; - for (const keyOption of Object.keys(options.category.options)) { - output.push(t(`permissions:${keyOption}`, keyOption)); - } - return output; - } - - private async fetchMe() { - return this.guild.members.fetch(process.env.CLIENT_ID); - } - - private async sendDM(entry: ModerationEntity, sendOptions: ModerationActionsSendOptions = {}) { - if (sendOptions.send) { - try { - const target = await entry.fetchUser(); - const embed = await this.buildEmbed(entry, sendOptions); - await resolveOnErrorCodes(target.send({ embeds: [embed] }), RESTJSONErrorCodes.CannotSendMessagesToThisUser); - } catch (error) { - container.logger.error(error); - } - } - } - - private async buildEmbed(entry: ModerationEntity, sendOptions: ModerationActionsSendOptions) { - const descriptionKey = entry.reason - ? entry.duration - ? LanguageKeys.Commands.Moderation.ModerationDmDescriptionWithReasonWithDuration - : LanguageKeys.Commands.Moderation.ModerationDmDescriptionWithReason - : entry.duration - ? LanguageKeys.Commands.Moderation.ModerationDmDescriptionWithDuration - : LanguageKeys.Commands.Moderation.ModerationDmDescription; - - const t = await fetchT(this.guild); - const description = t(descriptionKey, { - guild: this.guild.name, - title: entry.title, - reason: entry.reason, - duration: entry.duration - }); - const embed = new EmbedBuilder() // - .setDescription(description) - .setFooter({ text: t(LanguageKeys.Commands.Moderation.ModerationDmFooter) }); - - if (sendOptions.moderator) embed.setAuthor(getFullEmbedAuthor(sendOptions.moderator)); - return embed; - } - - private async addStickyMute(id: string) { - const [roleId] = await readSettings(this.guild, (settings) => [settings.rolesMuted]); - if (isNullish(roleId)) throw new UserError({ identifier: LanguageKeys.Commands.Moderation.MuteNotConfigured }); - return getStickyRoles(this.guild).add(id, roleId); - } - - private async removeStickyMute(id: string) { - const [roleId] = await readSettings(this.guild, (settings) => [settings.rolesMuted]); - if (isNullish(roleId)) throw new UserError({ identifier: LanguageKeys.Commands.Moderation.MuteNotConfigured }); - return getStickyRoles(this.guild).remove(id, roleId); - } - - private async muteUser(rawOptions: ModerationActionOptions) { - try { - const member = await this.guild.members.fetch(rawOptions.userId); - return this.muteUserInGuild(member, await this.getReason('mute', rawOptions.reason || null)); - } catch (error) { - if ((error as DiscordAPIError).code === RESTJSONErrorCodes.UnknownMember) - throw await resolveKey(this.guild, LanguageKeys.Commands.Moderation.ActionRequiredMember); - throw error; - } - } - - private async muteUserInGuild(member: GuildMember, reason: string) { - const [roleId] = await readSettings(this.guild, (settings) => [settings.rolesMuted]); - if (isNullish(roleId)) throw new UserError({ identifier: LanguageKeys.Commands.Moderation.MuteNotConfigured }); - - const role = this.guild.roles.cache.get(roleId); - if (typeof role === 'undefined') { - await writeSettings(this.guild, [[GuildSettings.Roles.Muted, null]]); - throw new UserError({ identifier: LanguageKeys.Commands.Moderation.MuteNotConfigured }); - } - - const { position } = (await this.fetchMe()).roles.highest; - const extracted = ModerationActions.muteExtractRoles(member, position); - extracted.keepRoles.push(roleId); - - await member.edit({ roles: extracted.keepRoles, reason }); - return extracted.removedRoles; - } - - private async unmuteUser(options: ModerationManagerCreateData & { reason: string | null }, moderationLog: ModerationEntity | null) { - try { - const member = await this.guild.members.fetch(options.userId); - return moderationLog === null - ? this.unmuteUserInGuildWithoutData(member, await this.getReason('mute', options.reason, true)) - : this.unmuteUserInGuildWithData(member, await this.getReason('mute', options.reason, true), moderationLog); - } catch (error) { - if ((error as DiscordAPIError).code !== RESTJSONErrorCodes.UnknownMember) throw error; - } - } - - /** - * Unmute a user who is in a guild and has a running moderation log. - * @since 5.3.0 - * @param member The member to unmute - * @param reason The reason to send for audit logs when unmuting - * @param moderationLog The moderation manager that defined the formal mute - */ - private async unmuteUserInGuildWithData(member: GuildMember, reason: string, moderationLog: ModerationEntity) { - const roleId = await readSettings(this.guild, GuildSettings.Roles.Muted); - const { position } = (await this.fetchMe()).roles.highest; - const rawRoleIds = Array.isArray(moderationLog.extraData) ? (moderationLog.extraData as string[]) : []; - const roles = this.unmuteExtractRoles(member, roleId, position, rawRoleIds); - await member.edit({ roles, reason }); - - return roles; - } - - /** - * Unmute a user who is in a guild and does not have a running moderation log, e.g. when unmuting somebody who - * merely has the muted role. - * @since 5.3.0 - * @param member The member to unmute - * @param reason The reason to send for audit logs when unmuting - */ - private async unmuteUserInGuildWithoutData(member: GuildMember, reason: string) { - // Retrieve the role ID of the mute role, return false if it does not exist. - const [roleId] = await readSettings(this.guild, (settings) => [settings.rolesMuted]); - if (isNullish(roleId)) throw new UserError({ identifier: LanguageKeys.Commands.Moderation.MuteNotConfigured }); - - // Retrieve the role instance from the role ID, reset and return false if it does not exist. - const role = this.guild.roles.cache.get(roleId); - if (typeof role === 'undefined') { - await writeSettings(this.guild, [[GuildSettings.Roles.Muted, null]]); - throw new UserError({ identifier: LanguageKeys.Commands.Moderation.MuteNotConfigured }); - } - - // If the user has the role, begin processing the data. - if (member.roles.cache.has(roleId)) { - // Fetch self and check if the bot has enough role hierarchy to manage the role, return false when not. - const { position } = (await this.fetchMe()).roles.highest; - if (role.position >= position) throw new UserError({ identifier: LanguageKeys.Commands.Moderation.MuteLowHierarchy }); - - // Remove the role from the member. - await member.roles.remove(roleId, reason); - return; - } - - throw new UserError({ identifier: LanguageKeys.Commands.Moderation.MuteNotInMember }); - } - - private unmuteExtractRoles(member: GuildMember, roleId: string | Nullish, selfPosition: number, rawIdentifiers: readonly string[] | null) { - if (rawIdentifiers === null) rawIdentifiers = []; - - const rawRoles: Role[] = []; - for (const id of rawIdentifiers) { - const role = this.guild.roles.cache.get(id); - if (typeof role !== 'undefined') rawRoles.push(role); - } - - const roles = new Set(member.roles.cache.keys()); - for (const rawRole of rawRoles) { - if (rawRole.position < selfPosition) roles.add(rawRole.id); - } - - if (!isNullish(roleId)) roles.delete(roleId); - - return [...roles]; - } - - private async addStickyRestriction(id: string, key: GuildSettingsOfType) { - const [roleId] = await readSettings(this.guild, (settings) => [settings[key]]); - if (isNullish(roleId)) throw new UserError({ identifier: LanguageKeys.Misc.RestrictionNotConfigured }); - return getStickyRoles(this.guild).add(id, roleId); - } - - private async addRestrictionRole(id: string, key: GuildSettingsOfType) { - const [roleId] = await readSettings(this.guild, (settings) => [settings[key]]); - if (isNullish(roleId)) throw new UserError({ identifier: LanguageKeys.Misc.RestrictionNotConfigured }); - await api().guilds.addRoleToMember(this.guild.id, id, roleId); - } - - private async removeStickyRestriction(id: string, key: GuildSettingsOfType) { - const [roleId] = await readSettings(this.guild, (settings) => [settings[key]]); - if (isNullish(roleId)) throw new UserError({ identifier: LanguageKeys.Misc.RestrictionNotConfigured }); - return getStickyRoles(this.guild).remove(id, roleId); - } - - private async removeRestrictionRole(id: string, key: GuildSettingsOfType) { - const [roleId] = await readSettings(this.guild, (settings) => [settings[key]]); - if (isNullish(roleId)) throw new UserError({ identifier: LanguageKeys.Misc.RestrictionNotConfigured }); - try { - await api().guilds.removeRoleFromMember(this.guild.id, id, roleId); - } catch (error) { - if ((error as DiscordAPIError).code !== RESTJSONErrorCodes.UnknownMember) throw error; - } - } - - private async updatePermissionsForCategoryChannels(role: Role, dataKey: RoleDataKey) { - const options = kRoleChannelOverwriteOptions.get(dataKey)!; - const promises: Promise[] = []; - for (const channel of this.guild.channels.cache.values()) { - if (isCategoryChannel(channel) && channel.manageable) { - promises.push(ModerationActions.updatePermissionsForChannel(role, channel, options.category)); - } - } - - await Promise.all(promises); - } - - private async updatePermissionsForTextOrVoiceChannels(role: Role, dataKey: RoleDataKey) { - const options = kRoleChannelOverwriteOptions.get(dataKey)!; - const promises: Promise[] = []; - for (const channel of this.guild.channels.cache.values()) { - if (!channel.manageable) continue; - if (isTextChannel(channel) || isNewsChannel(channel)) { - promises.push(ModerationActions.updatePermissionsForChannel(role, channel, options.text)); - } else if (isVoiceChannel(channel) || isStageChannel(channel)) { - promises.push(ModerationActions.updatePermissionsForChannel(role, channel, options.voice)); - } - } - - await Promise.all(promises); - } - - /** - * Deletes the task from the last log from a user's cases - * @param userId The user ID to use when fetching - * @param type The type to retrieve for the invalidation - */ - private async cancelLastLogTaskFromUser(userId: string, type: TypeVariation, metadata: TypeMetadata, extra?: (log: ModerationEntity) => boolean) { - const log = await this.retrieveLastLogFromUser(userId, type, metadata, extra); - if (!log) return null; - - const { task } = log; - if (task && !task.running) await task.delete(); - return log; - } - - private async getReason(action: keyof ModerationAction, reason: string | null, revoke = false) { - const t = await fetchT(this.guild); - const actions = t(LanguageKeys.Commands.Moderation.Actions); - if (!reason) - return revoke - ? t(LanguageKeys.Commands.Moderation.ActionRevokeNoReason, { action: actions[action] }) - : t(LanguageKeys.Commands.Moderation.ActionApplyNoReason, { action: actions[action] }); - return revoke - ? t(LanguageKeys.Commands.Moderation.ActionRevokeReason, { action: actions[action], reason }) - : t(LanguageKeys.Commands.Moderation.ActionApplyReason, { action: actions[action], reason }); - } - - private async retrieveLastLogFromUser( - userId: string, - type: TypeVariation, - metadata: TypeMetadata, - extra: (log: ModerationEntity) => boolean = () => true - ) { - // Retrieve all moderation logs regarding a user. - const logs = await getModeration(this.guild).fetch(userId); - - // Filter all logs by valid and by type of mute (isType will include temporary and invisible). - return logs.filter((log) => !log.invalidated && log.type === type && log.metadata === metadata && extra(log)).last(); - } - - private static getRoleDataKeyFromSchemaKey(key: ModerationSetupRestriction): RoleDataKey { - switch (key) { - case ModerationSetupRestriction.All: - return RoleDataKey.Muted; - case ModerationSetupRestriction.Attachment: - return RoleDataKey.Attachment; - case ModerationSetupRestriction.Embed: - return RoleDataKey.Embed; - case ModerationSetupRestriction.Emoji: - return RoleDataKey.Emoji; - case ModerationSetupRestriction.Reaction: - return RoleDataKey.Reaction; - case ModerationSetupRestriction.Voice: - return RoleDataKey.Voice; - } - } - - private static fillOptions(rawOptions: ModerationActionOptions, type: TypeVariation, metadata: TypeMetadata = 0) { - const options = { reason: null, ...rawOptions, type, metadata }; - if (isNullishOrEmpty(options.reason)) options.reason = null; - if (isNullishOrEmpty(options.moderatorId)) options.moderatorId = process.env.CLIENT_ID; - if (isNullishOrZero(options.duration)) options.duration = null; - return options; - } - - private static muteExtractRoles(member: GuildMember, selfPosition: number) { - const keepRoles: string[] = []; - const removedRoles: string[] = []; - - // Iterate over all the member's roles. - for (const [id, role] of member.roles.cache.entries()) { - // Managed roles cannot be removed. - if (role.managed) keepRoles.push(id); - // Roles with higher hierarchy position cannot be removed. - else if (role.position >= selfPosition) keepRoles.push(id); - // Else it is fine to remove the role. - else removedRoles.push(id); - } - - return { keepRoles, removedRoles }; - } - - private static async updatePermissionsForChannel(role: Role, channel: GuildChannel, rolePermissions: RolePermissionOverwriteOptionField | null) { - if (rolePermissions === null) return; - - const current = channel.permissionOverwrites.cache.get(role.id); - if (typeof current === 'undefined') { - // If no permissions overwrites exists, create a new one. - await channel.permissionOverwrites.edit(role, rolePermissions.options, { reason: '[Setup] Updated channel for Muted Role.' }); - } else if (!current.deny.has(rolePermissions.permissions)) { - // If one exists and does not have the deny fields, tweak the existing one to keep all the allowed and - // denied, but also add the ones that must be denied for the mute role to work. - const allowed = current.allow.toArray().map((permission) => [permission, true]); - const denied = current.allow.toArray().map((permission) => [permission, false]); - const mixed = Object.fromEntries(allowed.concat(denied)); - await current.edit({ ...mixed, ...rolePermissions.options }); - } - } -} - -export interface ModerationActionsSendOptions { - send?: boolean; - moderator?: User | null; -} - -interface RolePermissionOverwriteOption { - category: RolePermissionOverwriteOptionField; - text: RolePermissionOverwriteOptionField | null; - voice: RolePermissionOverwriteOptionField | null; -} - -interface RolePermissionOverwriteOptionField { - options: PermissionOverwriteOptions; - permissions: bigint; -} - -export type ModerationActionOptions = Omit; diff --git a/src/lib/util/common/comparators.ts b/src/lib/util/common/comparators.ts index 59696b13dbf..e05e3934122 100644 --- a/src/lib/util/common/comparators.ts +++ b/src/lib/util/common/comparators.ts @@ -1,8 +1,8 @@ -export function asc(a: number, b: number): -1 | 0 | 1 { +export function asc(a: number | string | bigint, b: number | string | bigint): -1 | 0 | 1 { return a < b ? -1 : a > b ? 1 : 0; } -export function desc(a: number, b: number): -1 | 0 | 1 { +export function desc(a: number | string | bigint, b: number | string | bigint): -1 | 0 | 1 { return a > b ? -1 : a < b ? 1 : 0; } @@ -84,7 +84,7 @@ export function bidirectionalReplace(regex: RegExp, content: string, options: return results; } -export type BooleanFn = (...args: T) => R; +export type BooleanFn = (...args: ArgumentTypes) => ReturnType; export function andMix(...fns: readonly BooleanFn[]): BooleanFn { if (fns.length === 0) throw new Error('You must input at least one function.'); @@ -98,10 +98,12 @@ export function andMix(...fns: }; } -export function orMix(...fns: readonly BooleanFn[]): BooleanFn { +export function orMix( + ...fns: readonly BooleanFn[] +): BooleanFn { if (fns.length === 0) throw new Error('You must input at least one function.'); return (...args) => { - let ret!: R; + let ret!: ReturnType; for (const fn of fns) { if ((ret = fn(...args))) break; } diff --git a/src/lib/util/constants.ts b/src/lib/util/constants.ts index 7de9f9a0446..f99f2db97e9 100644 --- a/src/lib/util/constants.ts +++ b/src/lib/util/constants.ts @@ -38,6 +38,12 @@ export const enum Emojis { GreenTickSerialized = 's637706251253317669', Loading = '', RedCross = '<:redCross:637706251257511973>', + Calendar = '<:calendar_icon:1218607529702068294>', + Hourglass = '<:hourglass:1218608481565802587>', + Member = '<:member:1200212636441260103>', + ShieldMember = '<:shield_member:1218601473664094399>', + Moderator = '<:moderator:1218592235499688006>', + AutoModerator = '<:auto_moderator:1218600075606102077>', SpammerIcon = '<:spammer:1206893298292232245>', QuarantinedIcon = '<:quarantined:1206899526447923210>' } diff --git a/src/lib/util/moderationConstants.ts b/src/lib/util/moderationConstants.ts index 7958af141ea..a7e2ed7e1aa 100644 --- a/src/lib/util/moderationConstants.ts +++ b/src/lib/util/moderationConstants.ts @@ -1,12 +1,8 @@ -import { Colors } from '#utils/constants'; -import { isNullishOrZero } from '@sapphire/utilities'; - export const enum TypeVariation { Ban, Kick, Mute, - Prune, - SoftBan, + Softban, VoiceKick, VoiceMute, Warning, @@ -15,157 +11,20 @@ export const enum TypeVariation { RestrictedAttachment, RestrictedVoice, SetNickname, - AddRole, - RemoveRole, - RestrictedEmoji + RoleAdd, + RoleRemove, + RestrictedEmoji, + Timeout } export const enum TypeMetadata { None = 0, - Appeal = 1 << 0, + Undo = 1 << 0, Temporary = 1 << 1, + /** @deprecated Use Temporary instead */ Fast = 1 << 2, - Invalidated = 1 << 3 -} - -const TypeCodes = { - Warning: combineTypeData(TypeVariation.Warning), - Mute: combineTypeData(TypeVariation.Mute), - Kick: combineTypeData(TypeVariation.Kick), - SoftBan: combineTypeData(TypeVariation.SoftBan), - Ban: combineTypeData(TypeVariation.Ban), - VoiceMute: combineTypeData(TypeVariation.VoiceMute), - VoiceKick: combineTypeData(TypeVariation.VoiceKick), - RestrictedAttachment: combineTypeData(TypeVariation.RestrictedAttachment), - RestrictedReaction: combineTypeData(TypeVariation.RestrictedReaction), - RestrictedEmbed: combineTypeData(TypeVariation.RestrictedEmbed), - RestrictedEmoji: combineTypeData(TypeVariation.RestrictedEmoji), - RestrictedVoice: combineTypeData(TypeVariation.RestrictedVoice), - UnWarn: combineTypeData(TypeVariation.Warning, TypeMetadata.Appeal), - UnMute: combineTypeData(TypeVariation.Mute, TypeMetadata.Appeal), - UnBan: combineTypeData(TypeVariation.Ban, TypeMetadata.Appeal), - UnVoiceMute: combineTypeData(TypeVariation.VoiceMute, TypeMetadata.Appeal), - UnRestrictedReaction: combineTypeData(TypeVariation.RestrictedReaction, TypeMetadata.Appeal), - UnRestrictedEmbed: combineTypeData(TypeVariation.RestrictedEmbed, TypeMetadata.Appeal), - UnRestrictedEmoji: combineTypeData(TypeVariation.RestrictedEmoji, TypeMetadata.Appeal), - UnRestrictedAttachment: combineTypeData(TypeVariation.RestrictedAttachment, TypeMetadata.Appeal), - UnRestrictedVoice: combineTypeData(TypeVariation.RestrictedVoice, TypeMetadata.Appeal), - UnSetNickname: combineTypeData(TypeVariation.SetNickname, TypeMetadata.Appeal), - UnAddRole: combineTypeData(TypeVariation.AddRole, TypeMetadata.Appeal), - UnRemoveRole: combineTypeData(TypeVariation.RemoveRole, TypeMetadata.Appeal), - TemporaryWarning: combineTypeData(TypeVariation.Warning, TypeMetadata.Temporary), - TemporaryMute: combineTypeData(TypeVariation.Mute, TypeMetadata.Temporary), - TemporaryBan: combineTypeData(TypeVariation.Ban, TypeMetadata.Temporary), - TemporaryVoiceMute: combineTypeData(TypeVariation.VoiceMute, TypeMetadata.Temporary), - TemporaryRestrictedAttachment: combineTypeData(TypeVariation.RestrictedAttachment, TypeMetadata.Temporary), - TemporaryRestrictedReaction: combineTypeData(TypeVariation.RestrictedReaction, TypeMetadata.Temporary), - TemporaryRestrictedEmbed: combineTypeData(TypeVariation.RestrictedEmbed, TypeMetadata.Temporary), - TemporaryRestrictedEmoji: combineTypeData(TypeVariation.RestrictedEmoji, TypeMetadata.Temporary), - TemporaryRestrictedVoice: combineTypeData(TypeVariation.RestrictedVoice, TypeMetadata.Temporary), - TemporarySetNickname: combineTypeData(TypeVariation.SetNickname, TypeMetadata.Temporary), - TemporaryAddRole: combineTypeData(TypeVariation.AddRole, TypeMetadata.Temporary), - TemporaryRemoveRole: combineTypeData(TypeVariation.RemoveRole, TypeMetadata.Temporary), - FastTemporaryWarning: combineTypeData(TypeVariation.Warning, TypeMetadata.Temporary | TypeMetadata.Fast), - FastTemporaryMute: combineTypeData(TypeVariation.Mute, TypeMetadata.Temporary | TypeMetadata.Fast), - FastTemporaryBan: combineTypeData(TypeVariation.Ban, TypeMetadata.Temporary | TypeMetadata.Fast), - FastTemporaryVoiceMute: combineTypeData(TypeVariation.VoiceMute, TypeMetadata.Temporary | TypeMetadata.Fast), - FastTemporaryRestrictedAttachment: combineTypeData(TypeVariation.RestrictedAttachment, TypeMetadata.Temporary | TypeMetadata.Fast), - FastTemporaryRestrictedReaction: combineTypeData(TypeVariation.RestrictedReaction, TypeMetadata.Temporary | TypeMetadata.Fast), - FastTemporaryRestrictedEmbed: combineTypeData(TypeVariation.RestrictedEmbed, TypeMetadata.Temporary | TypeMetadata.Fast), - FastTemporaryRestrictedEmoji: combineTypeData(TypeVariation.RestrictedEmoji, TypeMetadata.Temporary | TypeMetadata.Fast), - FastTemporaryRestrictedVoice: combineTypeData(TypeVariation.RestrictedVoice, TypeMetadata.Temporary | TypeMetadata.Fast), - FastTemporarySetNickname: combineTypeData(TypeVariation.SetNickname, TypeMetadata.Temporary | TypeMetadata.Fast), - FastTemporaryAddRole: combineTypeData(TypeVariation.AddRole, TypeMetadata.Temporary | TypeMetadata.Fast), - FastTemporaryRemoveRole: combineTypeData(TypeVariation.RemoveRole, TypeMetadata.Temporary | TypeMetadata.Fast), - Prune: combineTypeData(TypeVariation.Prune), - SetNickname: combineTypeData(TypeVariation.SetNickname), - AddRole: combineTypeData(TypeVariation.AddRole), - RemoveRole: combineTypeData(TypeVariation.RemoveRole) -} as const; - -export type TypeCodes = number & { __TYPE__: 'TypeCodes' }; - -export function combineTypeData(type: TypeVariation, metadata?: TypeMetadata): TypeCodes { - if (isNullishOrZero(metadata)) return type as TypeCodes; - return (((metadata & ~TypeMetadata.Invalidated) << 4) | type) as TypeCodes; -} - -export function hasMetadata(type: TypeVariation, metadata?: TypeMetadata): boolean { - return Metadata.has(combineTypeData(type, metadata)); -} - -export function getMetadata(type: TypeVariation, metadata?: TypeMetadata): ModerationTypeAssets { - return Metadata.get(combineTypeData(type, metadata))!; -} - -const Metadata = new Map([ - [TypeCodes.Warning, { color: Colors.Yellow, title: 'Warning' }], - [TypeCodes.Mute, { color: Colors.Amber, title: 'Mute' }], - [TypeCodes.Kick, { color: Colors.Orange, title: 'Kick' }], - [TypeCodes.SoftBan, { color: Colors.DeepOrange, title: 'SoftBan' }], - [TypeCodes.Ban, { color: Colors.Red, title: 'Ban' }], - [TypeCodes.VoiceMute, { color: Colors.Amber, title: 'Voice Mute' }], - [TypeCodes.VoiceKick, { color: Colors.Orange, title: 'Voice Kick' }], - [TypeCodes.RestrictedReaction, { color: Colors.Lime, title: 'Reaction Restriction' }], - [TypeCodes.RestrictedEmbed, { color: Colors.Lime, title: 'Embed Restriction' }], - [TypeCodes.RestrictedEmoji, { color: Colors.Lime, title: 'Emoji Restriction' }], - [TypeCodes.RestrictedAttachment, { color: Colors.Lime, title: 'Attachment Restriction' }], - [TypeCodes.RestrictedVoice, { color: Colors.Lime, title: 'Voice Restriction' }], - [TypeCodes.UnWarn, { color: Colors.LightBlue, title: 'Reverted Warning' }], - [TypeCodes.UnMute, { color: Colors.LightBlue, title: 'Reverted Mute' }], - [TypeCodes.UnBan, { color: Colors.LightBlue, title: 'Reverted Ban' }], - [TypeCodes.UnVoiceMute, { color: Colors.LightBlue, title: 'Reverted Voice Mute' }], - [TypeCodes.UnRestrictedReaction, { color: Colors.LightBlue, title: 'Reverted Reaction Restriction' }], - [TypeCodes.UnRestrictedEmbed, { color: Colors.LightBlue, title: 'Reverted Embed Restriction' }], - [TypeCodes.UnRestrictedEmoji, { color: Colors.LightBlue, title: 'Reverted Emoji Restriction' }], - [TypeCodes.UnRestrictedAttachment, { color: Colors.LightBlue, title: 'Reverted Attachment Restriction' }], - [TypeCodes.UnRestrictedVoice, { color: Colors.LightBlue, title: 'Reverted Voice Restriction' }], - [TypeCodes.UnSetNickname, { color: Colors.LightBlue, title: 'Reverted Set Nickname' }], - [TypeCodes.UnAddRole, { color: Colors.LightBlue, title: 'Reverted Add Role' }], - [TypeCodes.UnRemoveRole, { color: Colors.LightBlue, title: 'Reverted Remove Role' }], - [TypeCodes.TemporaryWarning, { color: Colors.Yellow300, title: 'Temporary Warning' }], - [TypeCodes.TemporaryMute, { color: Colors.Amber300, title: 'Temporary Mute' }], - [TypeCodes.TemporaryBan, { color: Colors.Red300, title: 'Temporary Ban' }], - [TypeCodes.TemporaryVoiceMute, { color: Colors.Amber300, title: 'Temporary Voice Mute' }], - [TypeCodes.TemporaryRestrictedReaction, { color: Colors.Lime300, title: 'Temporary Reaction Restriction' }], - [TypeCodes.TemporaryRestrictedEmbed, { color: Colors.Lime300, title: 'Temporary Embed Restriction' }], - [TypeCodes.TemporaryRestrictedEmoji, { color: Colors.Lime300, title: 'Temporary Emoji Restriction' }], - [TypeCodes.TemporaryRestrictedAttachment, { color: Colors.Lime300, title: 'Temporary Attachment Restriction' }], - [TypeCodes.TemporaryRestrictedVoice, { color: Colors.Lime300, title: 'Temporary Voice Restriction' }], - [TypeCodes.TemporarySetNickname, { color: Colors.Lime300, title: 'Temporary Set Nickname' }], - [TypeCodes.TemporaryAddRole, { color: Colors.Lime300, title: 'Temporarily Added Role' }], - [TypeCodes.TemporaryRemoveRole, { color: Colors.Lime300, title: 'Temporarily Removed Role' }], - [TypeCodes.FastTemporaryWarning, { color: Colors.Yellow300, title: 'Temporary Warning' }], - [TypeCodes.FastTemporaryMute, { color: Colors.Amber300, title: 'Temporary Mute' }], - [TypeCodes.FastTemporaryBan, { color: Colors.Red300, title: 'Temporary Ban' }], - [TypeCodes.FastTemporaryVoiceMute, { color: Colors.Amber300, title: 'Temporary Voice Mute' }], - [TypeCodes.FastTemporaryRestrictedReaction, { color: Colors.Lime300, title: 'Temporary Reaction Restriction' }], - [TypeCodes.FastTemporaryRestrictedEmbed, { color: Colors.Lime300, title: 'Temporary Embed Restriction' }], - [TypeCodes.FastTemporaryRestrictedEmoji, { color: Colors.Lime300, title: 'Temporary Emoji Restriction' }], - [TypeCodes.FastTemporaryRestrictedAttachment, { color: Colors.Lime300, title: 'Temporary Attachment Restriction' }], - [TypeCodes.FastTemporaryRestrictedVoice, { color: Colors.Lime300, title: 'Temporary Voice Restriction' }], - [TypeCodes.FastTemporarySetNickname, { color: Colors.Lime300, title: 'Temporary Set Nickname' }], - [TypeCodes.FastTemporaryAddRole, { color: Colors.Lime300, title: 'Temporarily Added Role' }], - [TypeCodes.FastTemporaryRemoveRole, { color: Colors.Lime300, title: 'Temporarily Removed Role' }], - [TypeCodes.Prune, { color: Colors.Brown, title: 'Prune' }], - [TypeCodes.SetNickname, { color: Colors.Lime, title: 'Set Nickname' }], - [TypeCodes.AddRole, { color: Colors.Lime, title: 'Added Role' }], - [TypeCodes.RemoveRole, { color: Colors.Lime, title: 'Removed Role' }] -]) as ReadonlyMap; - -export const enum TypeVariationAppealNames { - Warning = 'moderationEndWarning', - Mute = 'moderationEndMute', - Ban = 'moderationEndBan', - VoiceMute = 'moderationEndVoiceMute', - RestrictedReaction = 'moderationEndRestrictionReaction', - RestrictedEmbed = 'moderationEndRestrictionEmbed', - RestrictedEmoji = 'moderationEndRestrictionEmoji', - RestrictedAttachment = 'moderationEndRestrictionAttachment', - RestrictedVoice = 'moderationEndRestrictionVoice', - SetNickname = 'moderationEndSetNickname', - AddRole = 'moderationEndAddRole', - RemoveRole = 'moderationEndRemoveRole' + Archived = 1 << 3, + Completed = 1 << 4 } export const enum SchemaKeys { @@ -186,13 +45,6 @@ export interface ModerationTypeAssets { title: string; } -export interface ModerationManagerDescriptionData { - reason: string | null; - prefix: string; - caseId: number; - formattedDuration: string; -} - export interface Unlock { unlock(): void; } diff --git a/src/lib/util/resolvers/Case.ts b/src/lib/util/resolvers/Case.ts new file mode 100644 index 00000000000..554b916d966 --- /dev/null +++ b/src/lib/util/resolvers/Case.ts @@ -0,0 +1,27 @@ +import { LanguageKeys } from '#lib/i18n/languageKeys'; +import { translate } from '#lib/i18n/translate'; +import type { ModerationManager } from '#lib/moderation'; +import { getModeration } from '#utils/functions'; +import { Resolvers, Result, UserError, err, ok } from '@sapphire/framework'; +import type { TFunction } from '@sapphire/plugin-i18next'; +import type { Guild } from 'discord.js'; + +export async function resolveCaseId(parameter: string, t: TFunction, guild: Guild): Promise> { + const maximum = await getModeration(guild).getCurrentId(); + if (maximum === 0) return err(new UserError({ identifier: LanguageKeys.Arguments.CaseNoEntries })); + + if (t(LanguageKeys.Arguments.CaseLatestOptions).includes(parameter)) return ok(maximum); + return Resolvers.resolveInteger(parameter, { minimum: 1, maximum }) // + .mapErr((error) => new UserError({ identifier: translate(error), context: { parameter, minimum: 1, maximum } })); +} + +export async function resolveCase(parameter: string, t: TFunction, guild: Guild): Promise> { + const result = await resolveCaseId(parameter, t, guild); + return result.match({ + ok: async (value) => { + const entry = await getModeration(guild).fetch(value); + return entry ? ok(entry) : err(new UserError({ identifier: LanguageKeys.Arguments.CaseUnknownEntry, context: { parameter } })); + }, + err: (error) => err(error) + }); +} diff --git a/src/lib/util/resolvers/TimeSpan.ts b/src/lib/util/resolvers/TimeSpan.ts new file mode 100644 index 00000000000..a9669d6b6e8 --- /dev/null +++ b/src/lib/util/resolvers/TimeSpan.ts @@ -0,0 +1,41 @@ +import { LanguageKeys } from '#lib/i18n/languageKeys'; +import type { Parameter, TypedFT } from '#lib/types'; +import { seconds } from '#utils/common'; +import { Result, err, ok } from '@sapphire/framework'; +import { Duration } from '@sapphire/time-utilities'; + +export function resolveTimeSpan(parameter: string, options?: TimeSpanOptions): Result> { + const duration = parse(parameter); + + if (!Number.isSafeInteger(duration)) { + return err(LanguageKeys.Arguments.TimeSpan); + } + + if (typeof options?.minimum === 'number' && duration < options.minimum) { + return err(LanguageKeys.Arguments.TimeSpanTooSmall); + } + + if (typeof options?.maximum === 'number' && duration > options.maximum) { + return err(LanguageKeys.Arguments.TimeSpanTooBig); + } + + return ok(duration); +} + +function parse(parameter: string) { + const number = Number(parameter); + if (!Number.isNaN(number)) return seconds(number); + + const duration = new Duration(parameter).offset; + if (!Number.isNaN(duration)) return duration; + + const date = Date.parse(parameter); + if (!Number.isNaN(date)) return date - Date.now(); + + return NaN; +} + +export interface TimeSpanOptions { + minimum?: number; + maximum?: number; +} diff --git a/src/lib/util/resolvers/index.ts b/src/lib/util/resolvers/index.ts new file mode 100644 index 00000000000..606873c8e11 --- /dev/null +++ b/src/lib/util/resolvers/index.ts @@ -0,0 +1,2 @@ +export * from '#utils/resolvers/Case'; +export * from '#utils/resolvers/TimeSpan'; diff --git a/src/lib/util/util.ts b/src/lib/util/util.ts index eb2e7be2847..48e53aeb900 100644 --- a/src/lib/util/util.ts +++ b/src/lib/util/util.ts @@ -16,6 +16,7 @@ import { type ImageURLOptions, type Message, type MessageMentionTypes, + type Snowflake, type ThreadChannel, type User, type UserResolvable @@ -367,6 +368,15 @@ export function getColor(message: Message) { return message.member?.displayColor ?? BrandingColors.Primary; } +/** + * Checks if the provided user ID is the same as the client's ID. + * + * @param userId - The user ID to check. + */ +export function isUserSelf(userId: Snowflake) { + return userId === process.env.CLIENT_ID; +} + export interface UtilOneToTenEntry { emoji: string; color: number; diff --git a/src/listeners/commands/_chat-input-shared.ts b/src/listeners/commands/_chat-input-shared.ts new file mode 100644 index 00000000000..f06771e6c21 --- /dev/null +++ b/src/listeners/commands/_chat-input-shared.ts @@ -0,0 +1,17 @@ +import { getSupportedUserLanguageT } from '#lib/i18n/translate'; +import { type ChatInputCommandErrorPayload } from '@sapphire/framework'; +import type { ChatInputSubcommandErrorPayload } from '@sapphire/plugin-subcommands'; +import { flattenError, generateUnexpectedErrorMessage, resolveError } from './_shared.js'; + +export async function handleCommandError(error: unknown, payload: ChatInputCommandErrorPayload | ChatInputSubcommandErrorPayload) { + const { interaction } = payload; + const t = getSupportedUserLanguageT(interaction); + const resolved = flattenError(payload.command, error); + const content = resolved ? resolveError(t, resolved) : generateUnexpectedErrorMessage(interaction.user.id, payload.command, t, error); + + try { + if (interaction.replied) await interaction.followUp({ content, ephemeral: true }); + else if (interaction.deferred) await interaction.editReply({ content }); + else await interaction.reply({ content, ephemeral: true }); + } catch {} +} diff --git a/src/listeners/commands/_message-shared.ts b/src/listeners/commands/_message-shared.ts new file mode 100644 index 00000000000..fd60189c042 --- /dev/null +++ b/src/listeners/commands/_message-shared.ts @@ -0,0 +1,177 @@ +import { LanguageKeys } from '#lib/i18n/languageKeys'; +import { fetchT, translate } from '#lib/i18n/translate'; +import type { SkyraArgs } from '#lib/structures'; +import { Colors, ZeroWidthSpace, rootFolder } from '#utils/constants'; +import { sendTemporaryMessage } from '#utils/functions'; +import { EmbedBuilder } from '@discordjs/builders'; +import { ArgumentError, Command, Events, UserError, container, type MessageCommandErrorPayload } from '@sapphire/framework'; +import type { TFunction } from '@sapphire/plugin-i18next'; +import type { MessageSubcommandErrorPayload } from '@sapphire/plugin-subcommands'; +import { codeBlock, cutText, type NonNullObject } from '@sapphire/utilities'; +import { DiscordAPIError, HTTPError, RESTJSONErrorCodes, Routes, type Message } from 'discord.js'; +import { generateUnexpectedErrorMessage } from './_shared.js'; + +const ignoredCodes = [RESTJSONErrorCodes.UnknownChannel, RESTJSONErrorCodes.UnknownMessage]; + +export async function handleCommandError(error: unknown, payload: MessageCommandErrorPayload | MessageSubcommandErrorPayload) { + const { message, command } = payload; + let t: TFunction; + let parameters: string; + if ('args' in payload) { + t = (payload.args as SkyraArgs).t; + parameters = payload.parameters; + } else { + t = await fetchT({ guild: message.guild, channel: message.channel, user: message.author }); + parameters = message.content.slice(payload.context.commandPrefix.length + payload.context.commandName.length).trim(); + } + + // If the error was a string or an UserError, send it to the user: + if (!(error instanceof Error)) return stringError(message, t, String(error)); + if (error instanceof ArgumentError) return argumentError(message, t, error); + if (error instanceof UserError) return userError(message, t, error); + + const { client, logger } = container; + // If the error was an AbortError or an Internal Server Error, tell the user to re-try: + if (error.name === 'AbortError' || error.message === 'Internal Server Error') { + logger.warn(`${getWarnError(message)} (${message.author.id}) | ${error.constructor.name}`); + return sendTemporaryMessage(message, t(LanguageKeys.System.DiscordAbortError)); + } + + // Extract useful information about the DiscordAPIError + if (error instanceof DiscordAPIError) { + if (isSilencedError(message, error)) return; + client.emit(Events.Error, error); + } else { + logger.warn(`${getWarnError(message)} (${message.author.id}) | ${error.constructor.name}`); + } + + // Send a detailed message: + await sendErrorChannel(message, command, parameters, error); + + // Emit where the error was emitted + logger.fatal(`[COMMAND] ${command.location.full}\n${error.stack || error.message}`); + try { + await sendTemporaryMessage(message, generateUnexpectedErrorMessage(message.author.id, command, t, error)); + } catch (err) { + client.emit(Events.Error, err); + } + + return undefined; +} + +function isSilencedError(message: Message, error: DiscordAPIError) { + return ( + // If it's an unknown channel or an unknown message, ignore: + ignoredCodes.includes(error.code as number) || + // If it's a DM message reply after a block, ignore: + isDirectMessageReplyAfterBlock(message, error) + ); +} + +function isDirectMessageReplyAfterBlock(message: Message, error: DiscordAPIError) { + // When sending a message to a user who has blocked the bot, Discord replies with 50007 "Cannot send messages to this user": + if (error.code !== RESTJSONErrorCodes.CannotSendMessagesToThisUser) return false; + + // If it's not a Direct Message, return false: + if (message.guild !== null) return false; + + // If the query was made to the message's channel, then it was a DM response: + return error.url === Routes.channelMessages(message.channel.id); +} + +function stringError(message: Message, t: TFunction, error: string) { + return alert(message, t(LanguageKeys.Events.Errors.String, { mention: message.author.toString(), message: error })); +} + +function argumentError(message: Message, t: TFunction, error: ArgumentError) { + const argument = error.argument.name; + const identifier = translate(error.identifier); + const parameter = error.parameter.replaceAll('`', '῾'); + return alert(message, t(identifier, { ...error, ...(error.context as NonNullObject), argument, parameter: cutText(parameter, 50) })); +} + +function userError(message: Message, t: TFunction, error: UserError) { + // `context: { silent: true }` should make UserError silent: + // Use cases for this are for example permissions error when running the `eval` command. + if (Reflect.get(Object(error.context), 'silent')) return; + + const identifier = translate(error.identifier); + const content = t(identifier, error.context as any) as string; + return alert(message, content); +} + +function alert(message: Message, content: string) { + return sendTemporaryMessage(message, { content, allowedMentions: { users: [message.author.id], roles: [] } }); +} + +async function sendErrorChannel(message: Message, command: Command, parameters: string, error: Error) { + const webhook = container.client.webhookError; + if (webhook === null) return; + + const lines = [getLinkLine(message.url), getCommandLine(command), getArgumentsLine(parameters), getErrorLine(error)]; + + // If it's a DiscordAPIError or a HTTPError, add the HTTP path and code lines after the second one. + if (error instanceof DiscordAPIError || error instanceof HTTPError) { + lines.splice(2, 0, getPathLine(error), getCodeLine(error)); + } + + const embed = new EmbedBuilder().setDescription(lines.join('\n')).setColor(Colors.Red).setTimestamp(); + try { + await webhook.send({ embeds: [embed] }); + } catch (err) { + container.client.emit(Events.Error, err); + } +} + +/** + * Formats a message url line. + * @param url The url to format. + */ +function getLinkLine(url: string): string { + return `[**Jump to Message!**](${url})`; +} + +/** + * Formats a command line. + * @param command The command to format. + */ +function getCommandLine(command: Command): string { + return `**Command**: ${command.location.full.slice(rootFolder.length)}`; +} + +/** + * Formats an error path line. + * @param error The error to format. + */ +function getPathLine(error: DiscordAPIError | HTTPError): string { + return `**Path**: ${error.method.toUpperCase()} ${error.url}`; +} + +/** + * Formats an error code line. + * @param error The error to format. + */ +function getCodeLine(error: DiscordAPIError | HTTPError): string { + return `**Code**: ${'code' in error ? error.code : error.status}`; +} + +/** + * Formats an arguments line. + * @param parameters The arguments the user used when running the command. + */ +function getArgumentsLine(parameters: string): string { + if (parameters.length === 0) return '**Parameters**: Not Supplied'; + return `**Parameters**: [\`${parameters.trim().replaceAll('`', '῾') || ZeroWidthSpace}\`]`; +} + +/** + * Formats an error codeblock. + * @param error The error to format. + */ +function getErrorLine(error: Error): string { + return `**Error**: ${codeBlock('js', error.stack || error.toString())}`; +} + +function getWarnError(message: Message) { + return `ERROR: /${message.guild ? `${message.guild.id}/${message.channel.id}` : `DM/${message.author.id}`}/${message.id}`; +} diff --git a/src/listeners/commands/_shared.ts b/src/listeners/commands/_shared.ts index db1bcec9cba..70afb81415e 100644 --- a/src/listeners/commands/_shared.ts +++ b/src/listeners/commands/_shared.ts @@ -1,193 +1,106 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { fetchT, translate } from '#lib/i18n/translate'; -import type { SkyraArgs } from '#lib/structures'; +import { translate } from '#lib/i18n/translate'; import { OWNERS } from '#root/config'; -import { Colors, ZeroWidthSpace, rootFolder } from '#utils/constants'; -import { sendTemporaryMessage } from '#utils/functions'; -import { EmbedBuilder } from '@discordjs/builders'; -import { ArgumentError, Command, Events, UserError, container, type MessageCommandErrorPayload } from '@sapphire/framework'; +import { getCodeStyle, getLogPrefix } from '#utils/functions'; +import { ArgumentError, ResultError, UserError, container, type Command } from '@sapphire/framework'; import type { TFunction } from '@sapphire/plugin-i18next'; -import type { MessageSubcommandErrorPayload } from '@sapphire/plugin-subcommands'; -import { codeBlock, cutText, type NonNullObject } from '@sapphire/utilities'; +import { cutText } from '@sapphire/utilities'; import { captureException } from '@sentry/node'; import { envIsDefined } from '@skyra/env-utilities'; -import { DiscordAPIError, HTTPError, RESTJSONErrorCodes, Routes, type Message } from 'discord.js'; +import { DiscordAPIError, HTTPError, RESTJSONErrorCodes, codeBlock, type Snowflake } from 'discord.js'; +import { exists } from 'i18next'; -const ignoredCodes = [RESTJSONErrorCodes.UnknownChannel, RESTJSONErrorCodes.UnknownMessage]; +const Root = LanguageKeys.Errors; -export async function handleCommandError(error: unknown, payload: MessageCommandErrorPayload | MessageSubcommandErrorPayload) { - const { message, command } = payload; - let t: TFunction; - let parameters: string; - if ('args' in payload) { - t = (payload.args as SkyraArgs).t; - parameters = payload.parameters; - } else { - t = await fetchT({ guild: message.guild, channel: message.channel, user: message.author }); - parameters = message.content.slice(payload.context.commandPrefix.length + payload.context.commandName.length).trim(); - } - - // If the error was a string or an UserError, send it to the user: - if (!(error instanceof Error)) return stringError(message, t, String(error)); - if (error instanceof ArgumentError) return argumentError(message, t, error); - if (error instanceof UserError) return userError(message, t, error); +export function resolveError(t: TFunction, error: UserError | string) { + return typeof error === 'string' ? resolveStringError(t, error) : resolveUserError(t, error); +} - const { client, logger } = container; - // If the error was an AbortError or an Internal Server Error, tell the user to re-try: - if (error.name === 'AbortError' || error.message === 'Internal Server Error') { - logger.warn(`${getWarnError(message)} (${message.author.id}) | ${error.constructor.name}`); - return sendTemporaryMessage(message, t(LanguageKeys.System.DiscordAbortError)); - } +function resolveStringError(t: TFunction, error: string) { + return exists(error) ? (t(error) as string) : error; +} - // Extract useful information about the DiscordAPIError - if (error instanceof DiscordAPIError) { - if (isSilencedError(message, error)) return; - client.emit(Events.Error, error); - } else { - logger.warn(`${getWarnError(message)} (${message.author.id}) | ${error.constructor.name}`); - } +function resolveUserError(t: TFunction, error: UserError) { + const identifier = translate(error.identifier); + return t( + identifier, + error instanceof ArgumentError + ? { ...error, ...(error.context as object), argument: error.argument.name, parameter: cutText(error.parameter.replaceAll('`', '῾'), 50) } + : (error.context as any) + ) as string; +} - // Send a detailed message: - await sendErrorChannel(message, command, parameters, error); +export function flattenError(command: Command, error: unknown): UserError | string | null { + if (typeof error === 'string') return error; - // Emit where the error was emitted - logger.fatal(`[COMMAND] ${command.location.full}\n${error.stack || error.message}`); - try { - await sendTemporaryMessage(message, generateUnexpectedErrorMessage(message, command, t, error)); - } catch (err) { - client.emit(Events.Error, err); + if (!(error instanceof Error)) { + container.logger.fatal(getLogPrefix(command), 'Unknown unhandled error:', error); + return null; } - return undefined; -} + if (error instanceof ResultError) return flattenError(command, error.value); + if (error instanceof UserError) return error; -function isSilencedError(message: Message, error: DiscordAPIError) { - return ( - // If it's an unknown channel or an unknown message, ignore: - ignoredCodes.includes(error.code as number) || - // If it's a DM message reply after a block, ignore: - isDirectMessageReplyAfterBlock(message, error) - ); -} + if (error instanceof DiscordAPIError) { + container.logger.error(getLogPrefix(command), getCodeStyle(error.code.toString()), 'Unhandled error:', error); + return getDiscordError(error.code as number); + } -function isDirectMessageReplyAfterBlock(message: Message, error: DiscordAPIError) { - // When sending a message to a user who has blocked the bot, Discord replies with 50007 "Cannot send messages to this user": - if (error.code !== RESTJSONErrorCodes.CannotSendMessagesToThisUser) return false; + if (error instanceof HTTPError) { + container.logger.error(getLogPrefix(command), getCodeStyle(error.status.toString()), 'Unhandled error:', error); + return getHttpError(error.status); + } - // If it's not a Direct Message, return false: - if (message.guild !== null) return false; + if (error.name === 'AbortError') { + return LanguageKeys.System.DiscordAbortError; + } - // If the query was made to the message's channel, then it was a DM response: - return error.url === Routes.channelMessages(message.channel.id); + container.logger.fatal(getLogPrefix(command), error); + return null; } const sentry = envIsDefined('SENTRY_URL'); -function generateUnexpectedErrorMessage(message: Message, command: Command, t: TFunction, error: Error) { - if (OWNERS.includes(message.author.id)) return codeBlock('js', error.stack!); +export function generateUnexpectedErrorMessage(userId: Snowflake, command: Command, t: TFunction, error: unknown) { + if (OWNERS.includes(userId)) return codeBlock('js', String(error)); if (!sentry) return t(LanguageKeys.Events.Errors.UnexpectedError); try { const report = captureException(error, { tags: { command: command.name } }); return t(LanguageKeys.Events.Errors.UnexpectedErrorWithContext, { report }); } catch (error) { - container.client.emit(Events.Error, error); return t(LanguageKeys.Events.Errors.UnexpectedError); } } -function stringError(message: Message, t: TFunction, error: string) { - return alert(message, t(LanguageKeys.Events.Errors.String, { mention: message.author.toString(), message: error })); -} - -function argumentError(message: Message, t: TFunction, error: ArgumentError) { - const argument = error.argument.name; - const identifier = translate(error.identifier); - const parameter = error.parameter.replaceAll('`', '῾'); - return alert(message, t(identifier, { ...error, ...(error.context as NonNullObject), argument, parameter: cutText(parameter, 50) })); -} - -function userError(message: Message, t: TFunction, error: UserError) { - // `context: { silent: true }` should make UserError silent: - // Use cases for this are for example permissions error when running the `eval` command. - if (Reflect.get(Object(error.context), 'silent')) return; - - const identifier = translate(error.identifier); - const content = t(identifier, error.context as any) as string; - return alert(message, content); -} - -function alert(message: Message, content: string) { - return sendTemporaryMessage(message, { content, allowedMentions: { users: [message.author.id], roles: [] } }); -} - -async function sendErrorChannel(message: Message, command: Command, parameters: string, error: Error) { - const webhook = container.client.webhookError; - if (webhook === null) return; - - const lines = [getLinkLine(message.url), getCommandLine(command), getArgumentsLine(parameters), getErrorLine(error)]; - - // If it's a DiscordAPIError or a HTTPError, add the HTTP path and code lines after the second one. - if (error instanceof DiscordAPIError || error instanceof HTTPError) { - lines.splice(2, 0, getPathLine(error), getCodeLine(error)); +function getDiscordError(code: RESTJSONErrorCodes) { + switch (code) { + case RESTJSONErrorCodes.UnknownChannel: + return Root.GenericUnknownChannel; + case RESTJSONErrorCodes.UnknownGuild: + return Root.GenericUnknownGuild; + case RESTJSONErrorCodes.UnknownMember: + return Root.GenericUnknownMember; + case RESTJSONErrorCodes.UnknownMessage: + return Root.GenericUnknownMessage; + case RESTJSONErrorCodes.UnknownRole: + return Root.GenericUnknownRole; + case RESTJSONErrorCodes.MissingAccess: + return Root.GenericMissingAccess; + default: + return null; } - - const embed = new EmbedBuilder().setDescription(lines.join('\n')).setColor(Colors.Red).setTimestamp(); - try { - await webhook.send({ embeds: [embed] }); - } catch (err) { - container.client.emit(Events.Error, err); - } -} - -/** - * Formats a message url line. - * @param url The url to format. - */ -function getLinkLine(url: string): string { - return `[**Jump to Message!**](${url})`; } -/** - * Formats a command line. - * @param command The command to format. - */ -function getCommandLine(command: Command): string { - return `**Command**: ${command.location.full.slice(rootFolder.length)}`; -} - -/** - * Formats an error path line. - * @param error The error to format. - */ -function getPathLine(error: DiscordAPIError | HTTPError): string { - return `**Path**: ${error.method.toUpperCase()} ${error.url}`; -} - -/** - * Formats an error code line. - * @param error The error to format. - */ -function getCodeLine(error: DiscordAPIError | HTTPError): string { - return `**Code**: ${'code' in error ? error.code : error.status}`; -} - -/** - * Formats an arguments line. - * @param parameters The arguments the user used when running the command. - */ -function getArgumentsLine(parameters: string): string { - if (parameters.length === 0) return '**Parameters**: Not Supplied'; - return `**Parameters**: [\`${parameters.trim().replaceAll('`', '῾') || ZeroWidthSpace}\`]`; -} - -/** - * Formats an error codeblock. - * @param error The error to format. - */ -function getErrorLine(error: Error): string { - return `**Error**: ${codeBlock('js', error.stack || error.toString())}`; -} - -function getWarnError(message: Message) { - return `ERROR: /${message.guild ? `${message.guild.id}/${message.channel.id}` : `DM/${message.author.id}`}/${message.id}`; +function getHttpError(status: number) { + switch (status) { + case 500: + return Root.GenericDiscordInternalServerError; + case 502: + case 504: + return Root.GenericDiscordGateway; + case 503: + return Root.GenericDiscordUnavailable; + default: + return null; + } } diff --git a/src/listeners/commands/chatInputCommandError.ts b/src/listeners/commands/chatInputCommandError.ts index 62ee710c0f2..35616b0c7c1 100644 --- a/src/listeners/commands/chatInputCommandError.ts +++ b/src/listeners/commands/chatInputCommandError.ts @@ -1,7 +1,8 @@ import { Events, Listener, type ChatInputCommandErrorPayload } from '@sapphire/framework'; +import { handleCommandError } from './_chat-input-shared.js'; export class UserListener extends Listener { - public run(error: Error, payload: ChatInputCommandErrorPayload) { - this.container.logger.fatal(`[COMMAND] ${payload.command.location.full}\n${error.stack || error.message}`); + public run(error: unknown, payload: ChatInputCommandErrorPayload) { + return handleCommandError(error, payload); } } diff --git a/src/listeners/commands/chatInputSubcommandError.ts b/src/listeners/commands/chatInputSubcommandError.ts new file mode 100644 index 00000000000..c0ddbddfd27 --- /dev/null +++ b/src/listeners/commands/chatInputSubcommandError.ts @@ -0,0 +1,9 @@ +import { Listener } from '@sapphire/framework'; +import type { ChatInputSubcommandErrorPayload, SubcommandPluginEvents } from '@sapphire/plugin-subcommands'; +import { handleCommandError } from './_chat-input-shared.js'; + +export class UserListener extends Listener { + public run(error: Error, payload: ChatInputSubcommandErrorPayload) { + return handleCommandError(error, payload); + } +} diff --git a/src/listeners/commands/messageCommandError.ts b/src/listeners/commands/messageCommandError.ts index c1f8676ab42..ae80c18ef31 100644 --- a/src/listeners/commands/messageCommandError.ts +++ b/src/listeners/commands/messageCommandError.ts @@ -1,5 +1,5 @@ import { Events, Listener, type MessageCommandErrorPayload } from '@sapphire/framework'; -import { handleCommandError } from './_shared.js'; +import { handleCommandError } from './_message-shared.js'; export class UserListener extends Listener { public run(error: Error, payload: MessageCommandErrorPayload) { diff --git a/src/listeners/commands/messageSubcommandError.ts b/src/listeners/commands/messageSubcommandError.ts index 77bef5ff88a..59d42058a08 100644 --- a/src/listeners/commands/messageSubcommandError.ts +++ b/src/listeners/commands/messageSubcommandError.ts @@ -1,6 +1,6 @@ import { Listener } from '@sapphire/framework'; import type { MessageSubcommandErrorPayload, SubcommandPluginEvents } from '@sapphire/plugin-subcommands'; -import { handleCommandError } from './_shared.js'; +import { handleCommandError } from './_message-shared.js'; export class UserListener extends Listener { public run(error: unknown, payload: MessageSubcommandErrorPayload) { diff --git a/src/listeners/guilds/bans/guildBanAdd.ts b/src/listeners/guilds/bans/guildBanAdd.ts index 04bdb39c12a..e071741d0c7 100644 --- a/src/listeners/guilds/bans/guildBanAdd.ts +++ b/src/listeners/guilds/bans/guildBanAdd.ts @@ -1,6 +1,6 @@ import { GuildSettings, readSettings } from '#lib/database'; import { getModeration } from '#utils/functions'; -import { TypeMetadata, TypeVariation } from '#utils/moderationConstants'; +import { TypeVariation } from '#utils/moderationConstants'; import { Listener } from '@sapphire/framework'; import type { GuildBan } from 'discord.js'; @@ -10,13 +10,8 @@ export class UserListener extends Listener { const moderation = getModeration(guild); await moderation.waitLock(); - await moderation - .create({ - userId: user.id, - moderatorId: process.env.CLIENT_ID, - type: TypeVariation.Ban, - metadata: TypeMetadata.None - }) - .create(); + + if (moderation.checkSimilarEntryHasBeenCreated(TypeVariation.Ban, user.id)) return; + await moderation.insert(moderation.create({ user: user.id, type: TypeVariation.Ban })); } } diff --git a/src/listeners/guilds/bans/guildBanRemove.ts b/src/listeners/guilds/bans/guildBanRemove.ts index 4d37e407ece..82332e0c369 100644 --- a/src/listeners/guilds/bans/guildBanRemove.ts +++ b/src/listeners/guilds/bans/guildBanRemove.ts @@ -10,13 +10,8 @@ export class UserListener extends Listener { const moderation = getModeration(guild); await moderation.waitLock(); - await moderation - .create({ - userId: user.id, - moderatorId: process.env.CLIENT_ID, - type: TypeVariation.Ban, - metadata: TypeMetadata.Appeal - }) - .create(); + + if (moderation.checkSimilarEntryHasBeenCreated(TypeVariation.Ban, user.id)) return; + await moderation.insert(moderation.create({ user, type: TypeVariation.Ban, metadata: TypeMetadata.Undo })); } } diff --git a/src/listeners/guilds/members/rawMemberRemoveNotify.ts b/src/listeners/guilds/members/rawMemberRemoveNotify.ts index 6499ee3b3d9..230876cc210 100644 --- a/src/listeners/guilds/members/rawMemberRemoveNotify.ts +++ b/src/listeners/guilds/members/rawMemberRemoveNotify.ts @@ -56,7 +56,7 @@ export class UserListener extends Listener { const moderation = getModeration(guild); await moderation.waitLock(); - const latestLogForUser = moderation.getLatestLogForUser(user.id); + const latestLogForUser = moderation.getLatestRecentCachedEntryForUser(user.id); if (latestLogForUser === null) { return { @@ -69,7 +69,7 @@ export class UserListener extends Listener { return { kicked: latestLogForUser.type === TypeVariation.Kick, banned: latestLogForUser.type === TypeVariation.Ban, - softbanned: latestLogForUser.type === TypeVariation.SoftBan + softbanned: latestLogForUser.type === TypeVariation.Softban }; } diff --git a/src/listeners/mentionSpamExceeded.ts b/src/listeners/mentionSpamExceeded.ts index 0df88308c11..3269c31832b 100644 --- a/src/listeners/mentionSpamExceeded.ts +++ b/src/listeners/mentionSpamExceeded.ts @@ -2,7 +2,7 @@ import { GuildSettings, readSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { Events, type GuildMessage } from '#lib/types'; import { getModeration } from '#utils/functions'; -import { TypeMetadata, TypeVariation } from '#utils/moderationConstants'; +import { TypeVariation } from '#utils/moderationConstants'; import { getTag } from '#utils/util'; import { Listener } from '@sapphire/framework'; @@ -26,15 +26,7 @@ export class UserListener extends Listener { nms.delete(message.author.id); const reason = t(LanguageKeys.Events.NoMentionSpam.ModerationLog, { threshold }); - await moderation - .create({ - userId: message.author.id, - moderatorId: process.env.CLIENT_ID, - type: TypeVariation.Ban, - metadata: TypeMetadata.None, - reason - }) - .create(); + await moderation.insert(moderation.create({ user: message.author.id, type: TypeVariation.Ban, reason })); } finally { lock(); } diff --git a/src/listeners/moderation/moderationEntryAdd.ts b/src/listeners/moderation/moderationEntryAdd.ts index 394ab4c2f1c..9ff3bf0efc4 100644 --- a/src/listeners/moderation/moderationEntryAdd.ts +++ b/src/listeners/moderation/moderationEntryAdd.ts @@ -1,22 +1,27 @@ -import { GuildSettings, ModerationEntity, writeSettings } from '#lib/database'; +import { GuildSettings, writeSettings } from '#lib/database'; +import type { ModerationManager } from '#lib/moderation'; +import { getEmbed, getUndoTaskName } from '#lib/moderation/common'; import { resolveOnErrorCodes } from '#utils/common'; +import { getModeration } from '#utils/functions'; import { SchemaKeys } from '#utils/moderationConstants'; import { canSendEmbeds } from '@sapphire/discord.js-utilities'; import { Listener } from '@sapphire/framework'; +import { fetchT } from '@sapphire/plugin-i18next'; +import { isNullishOrZero } from '@sapphire/utilities'; import { RESTJSONErrorCodes } from 'discord.js'; export class UserListener extends Listener { - public run(entry: ModerationEntity) { + public run(entry: ModerationManager.Entry) { return Promise.all([this.sendMessage(entry), this.scheduleDuration(entry)]); } - private async sendMessage(entry: ModerationEntity) { - const channel = await entry.fetchChannel(); + private async sendMessage(entry: ModerationManager.Entry) { + const moderation = getModeration(entry.guild); + const channel = await moderation.fetchChannel(); if (channel === null || !canSendEmbeds(channel)) return; - const messageEmbed = await entry.prepareEmbed(); - - const options = { embeds: [messageEmbed] }; + const t = await fetchT(entry.guild); + const options = { embeds: [await getEmbed(t, entry)] }; try { await resolveOnErrorCodes(channel.send(options), RESTJSONErrorCodes.MissingAccess, RESTJSONErrorCodes.MissingPermissions); } catch (error) { @@ -24,21 +29,24 @@ export class UserListener extends Listener { } } - private async scheduleDuration(entry: ModerationEntity) { - const taskName = entry.duration === null ? null : entry.appealTaskName; - if (taskName !== null) { - await this.container.schedule - .add(taskName, entry.duration! + Date.now(), { - catchUp: true, - data: { - [SchemaKeys.Case]: entry.caseId, - [SchemaKeys.User]: entry.userId, - [SchemaKeys.Guild]: entry.guildId, - [SchemaKeys.Duration]: entry.duration, - [SchemaKeys.ExtraData]: entry.extraData - } - }) - .catch((error) => this.container.logger.fatal(error)); - } + private async scheduleDuration(entry: ModerationManager.Entry) { + if (isNullishOrZero(entry.duration)) return; + + const taskName = getUndoTaskName(entry.type); + if (taskName === null) return; + + await this.container.schedule + .add(taskName, entry.expiresTimestamp!, { + catchUp: true, + data: { + [SchemaKeys.Case]: entry.id, + [SchemaKeys.User]: entry.userId, + [SchemaKeys.Guild]: entry.guild.id, + [SchemaKeys.Type]: entry.type, + [SchemaKeys.Duration]: entry.duration, + [SchemaKeys.ExtraData]: entry.extraData + } + }) + .catch((error) => this.container.logger.fatal(error)); } } diff --git a/src/listeners/moderation/moderationEntryEdit.ts b/src/listeners/moderation/moderationEntryEdit.ts index 0335cf86cd4..67628b35f48 100644 --- a/src/listeners/moderation/moderationEntryEdit.ts +++ b/src/listeners/moderation/moderationEntryEdit.ts @@ -1,34 +1,54 @@ -import { GuildSettings, ModerationEntity, writeSettings } from '#lib/database'; +import { GuildSettings, ScheduleEntity, writeSettings } from '#lib/database'; +import type { ModerationManager } from '#lib/moderation'; +import { getEmbed, getUndoTaskName } from '#lib/moderation/common'; +import type { GuildMessage } from '#lib/types'; import { resolveOnErrorCodes } from '#utils/common'; +import { getModeration } from '#utils/functions'; import { SchemaKeys } from '#utils/moderationConstants'; +import { isUserSelf } from '#utils/util'; import { canSendEmbeds, type GuildTextBasedChannelTypes } from '@sapphire/discord.js-utilities'; import { Listener } from '@sapphire/framework'; -import { RESTJSONErrorCodes, type Embed, type Message } from 'discord.js'; +import { fetchT } from '@sapphire/plugin-i18next'; +import { isNullish, isNumber } from '@sapphire/utilities'; +import { DiscordAPIError, RESTJSONErrorCodes, type Collection, type Embed, type Message, type Snowflake } from 'discord.js'; export class UserListener extends Listener { - public run(old: ModerationEntity, entry: ModerationEntity) { - return Promise.all([this.cancelTask(old, entry), this.sendMessage(old, entry), this.scheduleDuration(old, entry)]); + public run(old: ModerationManager.Entry, entry: ModerationManager.Entry) { + return Promise.all([this.scheduleDuration(old, entry), this.sendMessage(old, entry)]); } - private async cancelTask(old: ModerationEntity, entry: ModerationEntity) { - // If the task was invalidated or had its duration set to null, delete any pending task - if ((!old.invalidated && entry.invalidated) || (old.duration !== null && entry.duration === null)) await entry.task?.delete(); + private async scheduleDuration(old: ModerationManager.Entry, entry: ModerationManager.Entry) { + // If the entry has been archived in this update, delete the task: + if (entry.isArchived() || entry.isCompleted()) { + await this.#tryDeleteTask(entry.task); + return; + } + + if (old.duration === entry.duration) return; + + const { task } = entry; + if (isNullish(task)) { + if (entry.duration !== null) await this.#createNewTask(entry); + } else if (entry.duration === null) { + // If the new duration is null, delete the previous task: + await this.#tryDeleteTask(task); + } else { + // If the new duration is not null, reschedule the previous task: + await task.reschedule(entry.expiresTimestamp!); + } } - private async sendMessage(old: ModerationEntity, entry: ModerationEntity) { + private async sendMessage(old: ModerationManager.Entry, entry: ModerationManager.Entry) { // Handle invalidation - if (!old.invalidated && entry.invalidated) return; + if (this.#isArchiveUpdate(old, entry)) return; - // If both logs are equals, skip - if (entry.equals(old)) return; - - const channel = await entry.fetchChannel(); + const moderation = getModeration(entry.guild); + const channel = await moderation.fetchChannel(); if (channel === null || !canSendEmbeds(channel)) return; - const messageEmbed = await entry.prepareEmbed(); - const previous = this.fetchModerationLogMessage(entry, channel); - - const options = { embeds: [messageEmbed] }; + const t = await fetchT(entry.guild); + const previous = await this.fetchModerationLogMessage(entry, channel); + const options = { embeds: [await getEmbed(t, entry)] }; try { await resolveOnErrorCodes( previous === null ? channel.send(options) : previous.edit(options), @@ -40,87 +60,89 @@ export class UserListener extends Listener { } } - private fetchModerationLogMessage(entry: ModerationEntity, channel: GuildTextBasedChannelTypes) { - if (entry.caseId === -1) throw new TypeError('UNREACHABLE.'); - - for (const message of channel.messages.cache.values()) { - if (this.validateModerationLogMessage(message, entry.caseId)) return message; + private async fetchModerationLogMessage(entry: ModerationManager.Entry, channel: GuildTextBasedChannelTypes) { + const messages = await this.fetchChannelMessages(channel); + for (const message of messages.values()) { + if (this.#validateModerationLogMessage(message, entry.id)) return message; } return null; } - private validateModerationLogMessage(message: Message, caseId: number) { + /** + * Fetch 100 messages from the modlogs channel + */ + private async fetchChannelMessages(channel: GuildTextBasedChannelTypes, remainingRetries = 5): Promise> { + try { + return (await channel.messages.fetch({ limit: 100 })) as Collection; + } catch (error) { + if (error instanceof DiscordAPIError) throw error; + return this.fetchChannelMessages(channel, --remainingRetries); + } + } + + async #tryDeleteTask(task: ScheduleEntity | null) { + if (!isNullish(task) && !task.running) await task.delete(); + } + + #validateModerationLogMessage(message: Message, caseId: number) { return ( - message.author.id === process.env.CLIENT_ID && + isUserSelf(message.author.id) && message.attachments.size === 0 && message.embeds.length === 1 && - this.validateModerationLogMessageEmbed(message.embeds[0]) && - message.embeds[0].footer!.text === `Case ${caseId}` + this.#validateModerationLogMessageEmbed(message.embeds[0]) && + message.embeds[0].footer!.text.includes(caseId.toString()) ); } - private validateModerationLogMessageEmbed(embed: Embed) { + #validateModerationLogMessageEmbed(embed: Embed) { return ( - this.validateModerationLogMessageEmbedAuthor(embed.author) && - this.validateModerationLogMessageEmbedDescription(embed.description) && - this.validateModerationLogMessageEmbedColor(embed.color) && - this.validateModerationLogMessageEmbedFooter(embed.footer) && - this.validateModerationLogMessageEmbedTimestamp(embed.timestamp) + this.#validateModerationLogMessageEmbedAuthor(embed.author) && + this.#validateModerationLogMessageEmbedDescription(embed.description) && + this.#validateModerationLogMessageEmbedColor(embed.color) && + this.#validateModerationLogMessageEmbedFooter(embed.footer) && + this.#validateModerationLogMessageEmbedTimestamp(embed.timestamp) ); } - private validateModerationLogMessageEmbedAuthor(author: Embed['author']) { + #validateModerationLogMessageEmbedAuthor(author: Embed['author']) { return author !== null && typeof author.name === 'string' && /\(\d{17,19}\)^/.test(author.name) && typeof author.iconURL === 'string'; } - private validateModerationLogMessageEmbedDescription(description: Embed['description']) { + #validateModerationLogMessageEmbedDescription(description: Embed['description']) { return typeof description === 'string' && description.split('\n').length >= 3; } - private validateModerationLogMessageEmbedColor(color: Embed['color']) { - return typeof color === 'number'; + #validateModerationLogMessageEmbedColor(color: Embed['color']) { + return isNumber(color); } - private validateModerationLogMessageEmbedFooter(footer: Embed['footer']) { + #validateModerationLogMessageEmbedFooter(footer: Embed['footer']) { return footer !== null && typeof footer.text === 'string' && typeof footer.iconURL === 'string'; } - private validateModerationLogMessageEmbedTimestamp(timestamp: Embed['timestamp']) { - return typeof timestamp === 'number'; + #validateModerationLogMessageEmbedTimestamp(timestamp: Embed['timestamp']) { + return isNumber(timestamp); } - private async scheduleDuration(old: ModerationEntity, entry: ModerationEntity) { - if (old.duration === entry.duration) return; - - const previous = this.retrievePreviousSchedule(entry); - if (previous !== null) await previous.delete(); - - const taskName = entry.duration === null ? null : entry.appealTaskName; - if (taskName !== null) { - await this.container.schedule - .add(taskName, entry.duration! + Date.now(), { - catchUp: true, - data: { - [SchemaKeys.Case]: entry.caseId, - [SchemaKeys.User]: entry.userId, - [SchemaKeys.Guild]: entry.guildId, - [SchemaKeys.Duration]: entry.duration - } - }) - .catch((error) => this.container.logger.fatal(error)); - } + #isArchiveUpdate(old: ModerationManager.Entry, entry: ModerationManager.Entry) { + return !old.isArchived() && entry.isArchived(); } - private retrievePreviousSchedule(entry: ModerationEntity) { - return ( - this.container.schedule.queue.find( - (task) => - typeof task.data === 'object' && - task.data !== null && - task.data[SchemaKeys.Case] === entry.caseId && - task.data[SchemaKeys.Guild] === entry.guild.id - ) || null - ); + async #createNewTask(entry: ModerationManager.Entry) { + const taskName = getUndoTaskName(entry.type); + if (isNullish(taskName)) return; + + await this.container.schedule.add(taskName, entry.expiresTimestamp!, { + catchUp: true, + data: { + [SchemaKeys.Case]: entry.id, + [SchemaKeys.User]: entry.userId, + [SchemaKeys.Guild]: entry.guild.id, + [SchemaKeys.Type]: entry.type, + [SchemaKeys.Duration]: entry.duration, + [SchemaKeys.ExtraData]: entry.extraData + } + }); } } diff --git a/src/tasks/moderation/moderationEndAddRole.ts b/src/tasks/moderation/moderationEndAddRole.ts index fba2c7ecd3a..8fbb8c1b83f 100644 --- a/src/tasks/moderation/moderationEndAddRole.ts +++ b/src/tasks/moderation/moderationEndAddRole.ts @@ -1,6 +1,5 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationTask, type ModerationData } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; +import { ModerationActions, ModerationTask, type ModerationData } from '#lib/moderation'; import { fetchT } from '@sapphire/plugin-i18next'; import { PermissionFlagsBits, type Guild, type Role } from 'discord.js'; @@ -10,15 +9,9 @@ export class UserModerationTask extends ModerationTask { if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) return null; const t = await fetchT(guild); - await getSecurity(guild).actions.unAddRole( - { - moderatorId: process.env.CLIENT_ID, - userId: data.userID, - reason: `[MODERATION] Role removed after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}` - }, - data.extraData.role, - await this.getTargetDM(guild, await this.container.client.users.fetch(data.userID)) - ); + const reason = `[MODERATION] Role removed after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}`; + const actionData = await this.getActionData(guild, data.userID, data.extraData.role); + await ModerationActions.roleAdd.undo(guild, { user: data.userID, reason }, actionData); return null; } } diff --git a/src/tasks/moderation/moderationEndBan.ts b/src/tasks/moderation/moderationEndBan.ts index bb6d60a1414..b0321288ce5 100644 --- a/src/tasks/moderation/moderationEndBan.ts +++ b/src/tasks/moderation/moderationEndBan.ts @@ -1,6 +1,5 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationTask, type ModerationData } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; +import { ModerationActions, ModerationTask, type ModerationData } from '#lib/moderation'; import { fetchT } from '@sapphire/plugin-i18next'; import { PermissionFlagsBits, type Guild } from 'discord.js'; @@ -10,14 +9,9 @@ export class UserModerationTask extends ModerationTask { if (!me.permissions.has(PermissionFlagsBits.BanMembers)) return null; const t = await fetchT(guild); - await getSecurity(guild).actions.unBan( - { - moderatorId: process.env.CLIENT_ID, - userId: data.userID, - reason: `[MODERATION] Ban released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}` - }, - await this.getTargetDM(guild, await this.container.client.users.fetch(data.userID)) - ); + const reason = `[MODERATION] Ban released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}`; + const actionData = await this.getActionData(guild, data.userID); + await ModerationActions.ban.undo(guild, { user: data.userID, reason }, actionData); return null; } } diff --git a/src/tasks/moderation/moderationEndMute.ts b/src/tasks/moderation/moderationEndMute.ts index cae6a61e61d..379a3bb942f 100644 --- a/src/tasks/moderation/moderationEndMute.ts +++ b/src/tasks/moderation/moderationEndMute.ts @@ -1,20 +1,14 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationTask, type ModerationData } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; +import { ModerationActions, ModerationTask, type ModerationData } from '#lib/moderation'; import { fetchT } from '@sapphire/plugin-i18next'; import type { Guild } from 'discord.js'; export class UserModerationTask extends ModerationTask { protected async handle(guild: Guild, data: ModerationData) { const t = await fetchT(guild); - await getSecurity(guild).actions.unMute( - { - moderatorId: process.env.CLIENT_ID, - userId: data.userID, - reason: `[MODERATION] Mute released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}` - }, - await this.getTargetDM(guild, await this.container.client.users.fetch(data.userID)) - ); + const reason = `[MODERATION] Mute released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}`; + const actionData = await this.getActionData(guild, data.userID); + await ModerationActions.mute.undo(guild, { user: data.userID, reason }, actionData); return null; } } diff --git a/src/tasks/moderation/moderationEndRemoveRole.ts b/src/tasks/moderation/moderationEndRemoveRole.ts index 125b83cc430..457ebf8f502 100644 --- a/src/tasks/moderation/moderationEndRemoveRole.ts +++ b/src/tasks/moderation/moderationEndRemoveRole.ts @@ -1,6 +1,5 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationTask, type ModerationData } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; +import { ModerationActions, ModerationTask, type ModerationData } from '#lib/moderation'; import { fetchT } from '@sapphire/plugin-i18next'; import { PermissionFlagsBits, type Guild, type Role } from 'discord.js'; @@ -10,15 +9,9 @@ export class UserModerationTask extends ModerationTask { if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) return null; const t = await fetchT(guild); - await getSecurity(guild).actions.unRemoveRole( - { - moderatorId: process.env.CLIENT_ID, - userId: data.userID, - reason: `[MODERATION] Role re-added after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}` - }, - data.extraData.role, - await this.getTargetDM(guild, await this.container.client.users.fetch(data.userID)) - ); + const reason = `[MODERATION] Role re-added after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}`; + const actionData = await this.getActionData(guild, data.userID, data.extraData.role); + await ModerationActions.roleRemove.undo(guild, { user: data.userID, reason }, actionData); return null; } } diff --git a/src/tasks/moderation/moderationEndRestrictionAttachment.ts b/src/tasks/moderation/moderationEndRestrictionAttachment.ts index 06bb2dd2bc0..bf57f24d90e 100644 --- a/src/tasks/moderation/moderationEndRestrictionAttachment.ts +++ b/src/tasks/moderation/moderationEndRestrictionAttachment.ts @@ -1,6 +1,5 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationTask, type ModerationData } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; +import { ModerationActions, ModerationTask, type ModerationData } from '#lib/moderation'; import { fetchT } from '@sapphire/plugin-i18next'; import { PermissionFlagsBits, type Guild } from 'discord.js'; @@ -10,14 +9,9 @@ export class UserModerationTask extends ModerationTask { if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) return null; const t = await fetchT(guild); - await getSecurity(guild).actions.unRestrictAttachment( - { - moderatorId: process.env.CLIENT_ID, - userId: data.userID, - reason: `[MODERATION] Attachment Restricted released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}` - }, - await this.getTargetDM(guild, await this.container.client.users.fetch(data.userID)) - ); + const reason = `[MODERATION] Attachment Restricted released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}`; + const actionData = await this.getActionData(guild, data.userID); + await ModerationActions.restrictedAttachment.undo(guild, { user: data.userID, reason }, actionData); return null; } } diff --git a/src/tasks/moderation/moderationEndRestrictionEmbed.ts b/src/tasks/moderation/moderationEndRestrictionEmbed.ts index 540df894ee5..83b89085e28 100644 --- a/src/tasks/moderation/moderationEndRestrictionEmbed.ts +++ b/src/tasks/moderation/moderationEndRestrictionEmbed.ts @@ -1,6 +1,5 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationTask, type ModerationData } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; +import { ModerationActions, ModerationTask, type ModerationData } from '#lib/moderation'; import { fetchT } from '@sapphire/plugin-i18next'; import { PermissionFlagsBits, type Guild } from 'discord.js'; @@ -10,14 +9,9 @@ export class UserModerationTask extends ModerationTask { if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) return null; const t = await fetchT(guild); - await getSecurity(guild).actions.unRestrictEmbed( - { - moderatorId: process.env.CLIENT_ID, - userId: data.userID, - reason: `[MODERATION] Embed Restricted released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}` - }, - await this.getTargetDM(guild, await this.container.client.users.fetch(data.userID)) - ); + const reason = `[MODERATION] Embed Restricted released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}`; + const actionData = await this.getActionData(guild, data.userID); + await ModerationActions.restrictedEmbed.undo(guild, { user: data.userID, reason }, actionData); return null; } } diff --git a/src/tasks/moderation/moderationEndRestrictionReaction.ts b/src/tasks/moderation/moderationEndRestrictionReaction.ts index aeb6bde8ecb..3d30464fcca 100644 --- a/src/tasks/moderation/moderationEndRestrictionReaction.ts +++ b/src/tasks/moderation/moderationEndRestrictionReaction.ts @@ -1,6 +1,5 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationTask, type ModerationData } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; +import { ModerationActions, ModerationTask, type ModerationData } from '#lib/moderation'; import { fetchT } from '@sapphire/plugin-i18next'; import { PermissionFlagsBits, type Guild } from 'discord.js'; @@ -10,14 +9,9 @@ export class UserModerationTask extends ModerationTask { if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) return null; const t = await fetchT(guild); - await getSecurity(guild).actions.unRestrictReaction( - { - moderatorId: process.env.CLIENT_ID, - userId: data.userID, - reason: `[MODERATION] Reaction Restricted released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}` - }, - await this.getTargetDM(guild, await this.container.client.users.fetch(data.userID)) - ); + const reason = `[MODERATION] Reaction Restricted released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}`; + const actionData = await this.getActionData(guild, data.userID); + await ModerationActions.restrictedReaction.undo(guild, { user: data.userID, reason }, actionData); return null; } } diff --git a/src/tasks/moderation/moderationEndRestrictionVoice.ts b/src/tasks/moderation/moderationEndRestrictionVoice.ts index cbbb8881594..7637f315cc3 100644 --- a/src/tasks/moderation/moderationEndRestrictionVoice.ts +++ b/src/tasks/moderation/moderationEndRestrictionVoice.ts @@ -1,6 +1,5 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationTask, type ModerationData } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; +import { ModerationActions, ModerationTask, type ModerationData } from '#lib/moderation'; import { fetchT } from '@sapphire/plugin-i18next'; import { PermissionFlagsBits, type Guild } from 'discord.js'; @@ -10,14 +9,9 @@ export class UserModerationTask extends ModerationTask { if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) return null; const t = await fetchT(guild); - await getSecurity(guild).actions.unRestrictVoice( - { - moderatorId: process.env.CLIENT_ID, - userId: data.userID, - reason: `[MODERATION] Voice Restricted released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}` - }, - await this.getTargetDM(guild, await this.container.client.users.fetch(data.userID)) - ); + const reason = `[MODERATION] Voice Restricted released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}`; + const actionData = await this.getActionData(guild, data.userID); + await ModerationActions.restrictedVoice.undo(guild, { user: data.userID, reason }, actionData); return null; } } diff --git a/src/tasks/moderation/moderationEndSetNickname.ts b/src/tasks/moderation/moderationEndSetNickname.ts index eb31195c588..77310674dce 100644 --- a/src/tasks/moderation/moderationEndSetNickname.ts +++ b/src/tasks/moderation/moderationEndSetNickname.ts @@ -1,6 +1,5 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationTask, type ModerationData } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; +import { ModerationActions, ModerationTask, type ModerationData } from '#lib/moderation'; import { fetchT } from '@sapphire/plugin-i18next'; import { PermissionFlagsBits, type Guild } from 'discord.js'; @@ -10,15 +9,9 @@ export class UserModerationTask extends ModerationTask { if (!me.permissions.has(PermissionFlagsBits.ManageNicknames)) return null; const t = await fetchT(guild); - await getSecurity(guild).actions.unSetNickname( - { - moderatorId: process.env.CLIENT_ID, - userId: data.userID, - reason: `[MODERATION] Nickname reverted after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}` - }, - data.extraData.oldName, - await this.getTargetDM(guild, await this.container.client.users.fetch(data.userID)) - ); + const reason = `[MODERATION] Nickname reverted after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}`; + const actionData = await this.getActionData(guild, data.userID, data.extraData.oldName); + await ModerationActions.setNickname.undo(guild, { user: data.userID, reason }, actionData); return null; } } diff --git a/src/tasks/moderation/moderationEndVoiceMute.ts b/src/tasks/moderation/moderationEndVoiceMute.ts index 2d2d8491d29..6354d09675f 100644 --- a/src/tasks/moderation/moderationEndVoiceMute.ts +++ b/src/tasks/moderation/moderationEndVoiceMute.ts @@ -1,6 +1,5 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationTask, type ModerationData } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; +import { ModerationActions, ModerationTask, type ModerationData } from '#lib/moderation'; import { fetchT } from '@sapphire/plugin-i18next'; import { PermissionFlagsBits, type Guild } from 'discord.js'; @@ -10,14 +9,9 @@ export class UserModerationTask extends ModerationTask { if (!me.permissions.has(PermissionFlagsBits.MuteMembers)) return null; const t = await fetchT(guild); - await getSecurity(guild).actions.unVoiceMute( - { - moderatorId: process.env.CLIENT_ID, - userId: data.userID, - reason: `[MODERATION] Voice Mute released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}` - }, - await this.getTargetDM(guild, await this.container.client.users.fetch(data.userID)) - ); + const reason = `[MODERATION] Voice Mute released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}`; + const actionData = await this.getActionData(guild, data.userID); + await ModerationActions.voiceMute.undo(guild, { user: data.userID, reason }, actionData); return null; } } diff --git a/src/tasks/moderation/moderationEndWarning.ts b/src/tasks/moderation/moderationEndWarning.ts index 2fc75f69cf9..fcc9668e86b 100644 --- a/src/tasks/moderation/moderationEndWarning.ts +++ b/src/tasks/moderation/moderationEndWarning.ts @@ -1,6 +1,5 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { ModerationTask, type ModerationData } from '#lib/moderation'; -import { getSecurity } from '#utils/functions'; +import { ModerationActions, ModerationTask, type ModerationData } from '#lib/moderation'; import { fetchT } from '@sapphire/plugin-i18next'; import { PermissionFlagsBits, type Guild } from 'discord.js'; @@ -10,15 +9,9 @@ export class UserModerationTask extends ModerationTask { if (!me.permissions.has(PermissionFlagsBits.BanMembers)) return null; const t = await fetchT(guild); - await getSecurity(guild).actions.unWarning( - { - moderatorId: process.env.CLIENT_ID, - userId: data.userID, - reason: `[MODERATION] Warning released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}` - }, - data.caseID, - await this.getTargetDM(guild, await this.container.client.users.fetch(data.userID)) - ); + const reason = `[MODERATION] Warning released after ${t(LanguageKeys.Globals.DurationValue, { value: data.duration })}`; + const actionData = await this.getActionData(guild, data.userID, data.caseID); + await ModerationActions.warning.undo(guild, { user: data.userID, reason }, actionData); return null; } } diff --git a/src/tsconfig.json b/src/tsconfig.json index d18a15c5f13..faa8a33d941 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -6,24 +6,28 @@ "rootDir": ".", "baseUrl": ".", "paths": { - "#lib/database": ["lib/database/index.js"], + "#utils/common": ["lib/util/common/index.js"], + "#utils/functions": ["lib/util/functions/index.js"], + "#utils/resolvers": ["lib/util/resolvers/index.js"], + "#utils/*": ["lib/util/*"], "#lib/database/entities": ["lib/database/entities/index.js"], "#lib/database/keys": ["lib/database/keys/index.js"], "#lib/database/settings": ["lib/database/settings/index.js"], + "#lib/database": ["lib/database/index.js"], "#lib/discord": ["lib/discord/index.js"], - "#lib/moderation": ["lib/moderation/index.js"], + "#lib/moderation/actions": ["lib/moderation/actions/index.js"], + "#lib/moderation/common": ["lib/moderation/common/index.js"], "#lib/moderation/managers": ["lib/moderation/managers/index.js"], "#lib/moderation/workers": ["lib/moderation/workers/index.js"], - "#lib/structures": ["lib/structures/index.js"], + "#lib/moderation": ["lib/moderation/index.js"], + "#lib/structures/data": ["lib/structures/data/index.js"], "#lib/structures/managers": ["lib/structures/managers/index.js"], + "#lib/structures": ["lib/structures/index.js"], "#lib/setup": ["lib/setup/index.js"], "#lib/types": ["lib/types/index.js"], "#lib/i18n/languageKeys": ["lib/i18n/languageKeys/index.js"], "#lib/*": ["lib/*"], "#languages": ["languages/index.js"], - "#utils/common": ["lib/util/common/index.js"], - "#utils/functions": ["lib/util/functions/index.js"], - "#utils/*": ["lib/util/*"], "#root/*": ["*"] } },