diff --git a/.vscode/settings.json b/.vscode/settings.json index 29a71f737..0c32ddfd8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,7 +7,7 @@ }, "eslint.workingDirectories": [{ "pattern": "./packages/*" }], "unocss.root": "./packages/website", - "i18n-ally.localesPaths": "./packages/yuudachi/locales", + "i18n-ally.localesPaths": ["./packages/yuudachi/locales", "apps/yuudachi/locales"], "i18n-ally.enabledFrameworks": ["i18next"], "i18n-ally.sourceLanguage": "en-US", "i18n-ally.displayLanguage": "en-US", diff --git a/apps/yuudachi/locales/en-US/translation.json b/apps/yuudachi/locales/en-US/translation.json index 61854c5a6..a1d7d0fef 100644 --- a/apps/yuudachi/locales/en-US/translation.json +++ b/apps/yuudachi/locales/en-US/translation.json @@ -79,6 +79,19 @@ "no_reason": "No reason", "show_history_target": "Show history for target {{- user}}", "show_history_author": "Show history for author {{- user}}" + }, + "update_restriction_level": { + "errors": { + "no_change": "The restriction level has not been changed.", + "no_reason": "No reason provided for updating the restriction level. Reason is required when changing to `Restricted` or `Blocked`." + }, + "pending": "Do you really want to change the report restriction level to `{{- level}}`?", + "confirm": { + "base": "Successfully changed the report restriction level to `{{- level}}`", + "enabled": "All the restrictions to create reports have been lifted.", + "restricted": "The creation of reports have been restricted. Members will be required to confirm their report before proceeding.\n\n**Reason:** {{- reason}}", + "blocked": "The creation of reports have been blocked. Members will not be able to create reports.\n\n**Reason:** {{- reason}}" + } } }, "warn": { @@ -358,14 +371,17 @@ "invalid_attachment": "Invalid attachment, only images are allowed.", "timed_out": "The report has timed out, please try again.", "bot": "You cannot report bots.", - "no_attachment_forward": "This user has already been recently reported, you must specify an attachment to forward if it helps the context of the report." + "no_attachment_forward": "This user has already been recently reported, you must specify an attachment to forward if it helps the context of the report.", + "disabled": "The creation of reports has been disabled by the server moderators for the following reason:\n```{{- reason}}```" }, "warnings": "**Attention:** We are not Discord and we **cannot** moderate {{- trust_and_safety}} issues.\n**Creating false reports may lead to moderation actions.**", "trust_and_safety_sub": "Trust & Safety", + "restriction": "The reports have been restricted by the moderators for the following reason:\n```{{- reason}}```\nIf your report does not fall under this category, please proceed with the report creation.", "buttons": { "forward": "Forward Message", "forward_attachment": "Forward Attachment", "execute": "Create Report", + "proceed": "Proceed with Report", "cancel": "Cancel", "discord_report": "Report to Discord" }, @@ -763,8 +779,16 @@ } }, "table": { - "success_titles": ["Case id", "Member id", "Username"], - "fail_titles": ["Member id", "Username", "Error"] + "success_titles": [ + "Case id", + "Member id", + "Username" + ], + "fail_titles": [ + "Member id", + "Username", + "Error" + ] } }, "report_log": { @@ -788,4 +812,4 @@ } } } -} +} \ No newline at end of file diff --git a/apps/yuudachi/migrations/1673411947-reports-restriction-level.js b/apps/yuudachi/migrations/1673411947-reports-restriction-level.js new file mode 100644 index 000000000..b8bf08356 --- /dev/null +++ b/apps/yuudachi/migrations/1673411947-reports-restriction-level.js @@ -0,0 +1,17 @@ +/** + * @param {import('postgres').Sql} sql + */ +export async function up(sql) { + await sql.unsafe(` + create type guild_reports_restriction_level as enum ('enabled', 'restricted', 'blocked'); + + alter table guild_settings + add column reports_restriction_level guild_reports_restriction_level not null default 'enabled'; + + alter table guild_settings + add column reports_restriction_reason text default null; + + comment on column guild_settings.reports_restriction_reason is 'The reason why reports are blocked/restricted in this guild'; + comment on column guild_settings.reports_restriction_level is 'The restriction_level of reports in this guild, whether they are enabled, restricted (requires confirmation), or blocked'; + `); +} diff --git a/apps/yuudachi/src/Constants.ts b/apps/yuudachi/src/Constants.ts index a44d931b0..03b0b1966 100644 --- a/apps/yuudachi/src/Constants.ts +++ b/apps/yuudachi/src/Constants.ts @@ -45,6 +45,12 @@ export const REPORT_DUPLICATE_PRE_EXPIRE_SECONDS = 3; export const REPORT_DUPLICATE_EXPIRE_SECONDS = 12 * 60 * 60; export const REPORT_MESSAGE_CONTEXT_LIMIT = 20; +export enum ReportsRestrictionLevel { + Blocked = "blocked", + Enabled = "enabled", + Restricted = "restricted", +} + export const APPEAL_REASON_MAX_LENGTH = 1_500; export const APPEAL_REASON_MIN_LENGTH = 160; diff --git a/apps/yuudachi/src/commands/moderation/report.ts b/apps/yuudachi/src/commands/moderation/report.ts index ebc67a8d2..a6908aaf4 100644 --- a/apps/yuudachi/src/commands/moderation/report.ts +++ b/apps/yuudachi/src/commands/moderation/report.ts @@ -1,12 +1,30 @@ -import { Command, logger, kRedis, createModal, createModalActionRow, createTextComponent } from "@yuudachi/framework"; +import { + Command, + logger, + kRedis, + createModal, + createModalActionRow, + createTextComponent, + createButton, + createMessageActionRow, +} from "@yuudachi/framework"; import type { ArgsParam, InteractionParam, LocaleParam, CommandMethod } from "@yuudachi/framework/types"; -import { type GuildMember, type User, type Message, TextInputStyle, ComponentType } from "discord.js"; +import { + TextInputStyle, + ComponentType, + ButtonStyle, + type ModalSubmitInteraction, + type ChatInputCommandInteraction, + type GuildMember, + type User, + type Message, +} from "discord.js"; import i18next from "i18next"; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import type { Redis } from "ioredis"; import { nanoid } from "nanoid"; import { inject, injectable } from "tsyringe"; -import { REPORT_REASON_MAX_LENGTH, REPORT_REASON_MIN_LENGTH } from "../../Constants.js"; +import { REPORT_REASON_MAX_LENGTH, REPORT_REASON_MIN_LENGTH, ReportsRestrictionLevel } from "../../Constants.js"; import type { Report } from "../../functions/reports/createReport.js"; import { getPendingReportByTarget } from "../../functions/reports/getReport.js"; import { checkLogChannel } from "../../functions/settings/checkLogChannel.js"; @@ -53,7 +71,23 @@ export default class extends Command< const { guildId, channelId, messageId } = parsedLink; const messageArg = await resolveMessage(interaction.channelId, guildId!, channelId!, messageId!, locale); - const pendingReport = await this.validateReport(interaction.member, messageArg.author, locale, messageArg); + const { pendingReport, restrictionLevel } = await this.validateReport( + interaction.member, + messageArg.author, + locale, + messageArg, + ); + + const canProceed = await this.showRestrictedConfirmationIfNecessary( + interaction, + restrictionLevel, + "message", + locale, + ); + + if (!canProceed) { + return; + } await message( interaction, @@ -64,19 +98,31 @@ export default class extends Command< locale, pendingReport, ); - } else { - if (!args.user.user.member) { - throw new Error(i18next.t("command.common.errors.target_not_found", { lng: locale })); - } - const pendingReport = await this.validateReport(interaction.member, args.user.user.user, locale); + return; + } + + if (!args.user.user.member) { + throw new Error(i18next.t("command.common.errors.target_not_found", { lng: locale })); + } - if (pendingReport && !args.user.attachment) { - throw new Error(i18next.t("command.mod.report.common.errors.no_attachment_forward", { lng: locale })); - } + const { pendingReport, restrictionLevel } = await this.validateReport( + interaction.member, + args.user.user.user, + locale, + ); - await user(interaction, args.user, locale, pendingReport); + if (pendingReport && !args.user.attachment) { + throw new Error(i18next.t("command.mod.report.common.errors.no_attachment_forward", { lng: locale })); } + + const canProceed = await this.showRestrictedConfirmationIfNecessary(interaction, restrictionLevel, "user", locale); + + if (!canProceed) { + return; + } + + await user(interaction, args.user, locale, pendingReport); } public override async userContext( @@ -90,6 +136,8 @@ export default class extends Command< throw new Error(i18next.t("common.errors.no_report_channel", { lng: locale })); } + const { pendingReport, restrictionLevel } = await this.validateReport(interaction.member, args.user.user, locale); + const modalKey = nanoid(); if (!args.user.member) { @@ -144,6 +192,17 @@ export default class extends Command< await modalInteraction.deferReply({ ephemeral: true }); + const canProceed = await this.showRestrictedConfirmationIfNecessary( + modalInteraction, + restrictionLevel, + "user", + locale, + ); + + if (!canProceed) { + return; + } + const reason = modalInteraction.components .flatMap((row) => row.components) .map((component) => (component.type === ComponentType.TextInput ? component.value || "" : "")); @@ -155,6 +214,7 @@ export default class extends Command< reason: reason.join(" "), }, locale, + pendingReport, ); } @@ -171,7 +231,12 @@ export default class extends Command< const modalKey = nanoid(); - const pendingReport = await this.validateReport(interaction.member, args.message.author, locale, args.message); + const { pendingReport, restrictionLevel } = await this.validateReport( + interaction.member, + args.message.author, + locale, + args.message, + ); const modal = createModal({ customId: modalKey, @@ -219,6 +284,17 @@ export default class extends Command< await modalInteraction.deferReply({ ephemeral: true }); + const canProceed = await this.showRestrictedConfirmationIfNecessary( + modalInteraction, + restrictionLevel, + "message", + locale, + ); + + if (!canProceed) { + return; + } + const reason = modalInteraction.components .flatMap((row) => row.components) .map((component) => (component.type === ComponentType.TextInput ? component.value || "" : "")); @@ -234,12 +310,85 @@ export default class extends Command< ); } + private async showRestrictedConfirmationIfNecessary( + interaction: ChatInputCommandInteraction<"cached"> | ModalSubmitInteraction<"cached">, + restrictionLevel: ReportsRestrictionLevel, + type: "message" | "user", + locale: string, + ): Promise { + if (restrictionLevel !== ReportsRestrictionLevel.Restricted) { + return true; + } + + const confirmationKey = nanoid(); + const cancelKey = nanoid(); + + const confirmationButton = createButton({ + label: i18next.t("command.mod.report.common.buttons.proceed", { lng: locale }), + customId: confirmationKey, + style: ButtonStyle.Danger, + }); + + const cancelButton = createButton({ + label: i18next.t("command.common.buttons.cancel", { lng: locale }), + customId: cancelKey, + style: ButtonStyle.Secondary, + }); + + const reason = await getGuildSetting(interaction.guildId, SettingsKeys.ReportsRestrictionReason); + + const reply = await interaction.editReply({ + content: i18next.t("command.mod.report.common.restriction", { lng: locale, reason }), + components: [createMessageActionRow([cancelButton, confirmationButton])], + }); + + const collectedInteraction = await reply + .awaitMessageComponent({ + filter: (collected) => collected.user.id === interaction.user.id, + componentType: ComponentType.Button, + time: 60_000, + }) + .catch(async () => { + try { + await interaction.editReply({ + content: i18next.t("command.common.errors.timed_out", { lng: locale }), + components: [], + }); + } catch (error_) { + const error = error_ as Error; + logger.error(error, error.message); + } + + return undefined; + }); + + if (!collectedInteraction) { + return false; + } + + if (collectedInteraction?.customId === cancelKey) { + await collectedInteraction.update({ + content: i18next.t(`command.mod.report.${type}.cancel`, { lng: locale }), + components: [], + }); + + return false; + } + + await collectedInteraction?.deferUpdate(); + + return true; + } + private async validateReport( author: GuildMember, target: User, locale: string, message?: Message, - ): Promise { + ): Promise<{ + pendingReport: Report | null; + restrictionLevel: ReportsRestrictionLevel; + }> { if (target.bot) { throw new Error(i18next.t("command.mod.report.common.errors.bot", { lng: locale })); } @@ -248,6 +397,16 @@ export default class extends Command< throw new Error(i18next.t("command.mod.report.common.errors.no_self", { lng: locale })); } + const restrictionLevel = await getGuildSetting( + author.guild.id, + SettingsKeys.ReportsRestrictionLevel, + ); + + if (restrictionLevel === ReportsRestrictionLevel.Blocked) { + const reason = await getGuildSetting(author.guild.id, SettingsKeys.ReportsRestrictionReason); + throw new Error(i18next.t("command.mod.report.common.errors.disabled", { lng: locale, reason })); + } + const userKey = `guild:${author.guild.id}:report:user:${target.id}`; const latestReport = await getPendingReportByTarget(author.guild.id, target.id); if (latestReport || (await this.redis.exists(userKey))) { @@ -260,6 +419,9 @@ export default class extends Command< } } - return latestReport; + return { + pendingReport: latestReport, + restrictionLevel, + }; } } diff --git a/apps/yuudachi/src/commands/moderation/reports.ts b/apps/yuudachi/src/commands/moderation/reports.ts index c3ac361a2..2df39e36b 100644 --- a/apps/yuudachi/src/commands/moderation/reports.ts +++ b/apps/yuudachi/src/commands/moderation/reports.ts @@ -4,6 +4,7 @@ import { handleReportAutocomplete } from "../../functions/autocomplete/reports.j import type { ReportUtilsCommand } from "../../interactions/index.js"; import { lookup } from "./sub/reports/lookup.js"; import { status } from "./sub/reports/status.js"; +import { updateRestrictionLevel } from "./sub/reports/update-restricition-level.js"; export default class extends Command { public override async autocomplete( @@ -19,7 +20,6 @@ export default class extends Command { args: ArgsParam, locale: LocaleParam, ): Promise { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition await interaction.deferReply({ ephemeral: args.lookup?.hide ?? true }); switch (Object.keys(args)[0]) { @@ -29,6 +29,9 @@ export default class extends Command { case "status": await status(interaction, args.status, locale); break; + case "update-restriction-level": + await updateRestrictionLevel(interaction, args["update-restriction-level"], locale); + break; default: } } diff --git a/apps/yuudachi/src/commands/moderation/sub/reports/update-restricition-level.ts b/apps/yuudachi/src/commands/moderation/sub/reports/update-restricition-level.ts new file mode 100644 index 000000000..67d4b8310 --- /dev/null +++ b/apps/yuudachi/src/commands/moderation/sub/reports/update-restricition-level.ts @@ -0,0 +1,47 @@ +import type { ArgsParam, InteractionParam, LocaleParam } from "@yuudachi/framework/types"; +import i18next from "i18next"; +import { ReportsRestrictionLevel } from "../../../../Constants.js"; +import { checkLogChannel } from "../../../../functions/settings/checkLogChannel.js"; +import { getGuildSetting, SettingsKeys } from "../../../../functions/settings/getGuildSetting.js"; +import { updateGuildSetting } from "../../../../functions/settings/updateGuildSetting.js"; +import type { ReportUtilsCommand } from "../../../../interactions/index.js"; + +export async function updateRestrictionLevel( + interaction: InteractionParam, + args: ArgsParam["update-restriction-level"], + locale: LocaleParam, +): Promise { + const reportLogChannel = checkLogChannel( + interaction.guild, + await getGuildSetting(interaction.guildId, SettingsKeys.ReportChannelId), + ); + + if (!reportLogChannel) { + throw new Error(i18next.t("common.errors.no_report_channel", { lng: locale })); + } + + if (args.level !== ReportsRestrictionLevel.Enabled && !args.reason?.length) { + throw new Error(i18next.t("command.mod.reports.update_restriction_level.errors.no_reason", { lng: locale })); + } + + const status = await getGuildSetting( + interaction.guildId, + SettingsKeys.ReportsRestrictionLevel, + ); + + if (status === args.level) { + throw new Error(i18next.t("command.mod.reports.update_restriction_level.errors.no_change", { lng: locale })); + } + + const reason = args.level === ReportsRestrictionLevel.Enabled ? null : args.reason; + + await updateGuildSetting(interaction.guildId, SettingsKeys.ReportsRestrictionLevel, args.level); + await updateGuildSetting(interaction.guildId, SettingsKeys.ReportsRestrictionReason, reason); + + await interaction.editReply({ + content: [ + i18next.t("command.mod.reports.update_restriction_level.confirm.base", { lng: locale, level: args.level }), + i18next.t(`command.mod.reports.update_restriction_level.confirm.${args.level}`, { lng: locale, reason }), + ].join("\n"), + }); +} diff --git a/apps/yuudachi/src/functions/settings/getGuildSetting.ts b/apps/yuudachi/src/functions/settings/getGuildSetting.ts index 0a4b848bc..98081cd13 100644 --- a/apps/yuudachi/src/functions/settings/getGuildSetting.ts +++ b/apps/yuudachi/src/functions/settings/getGuildSetting.ts @@ -18,6 +18,8 @@ export enum SettingsKeys { ReportChannelId = "report_channel_id", ReportStatusTags = "report_status_tags", ReportTypeTags = "report_type_tags", + ReportsRestrictionLevel = "reports_restriction_level", + ReportsRestrictionReason = "reports_restriction_reason", SponsorRoleId = "sponsor_role_id", } diff --git a/apps/yuudachi/src/functions/settings/updateGuildSetting.ts b/apps/yuudachi/src/functions/settings/updateGuildSetting.ts new file mode 100644 index 000000000..fdb7bba85 --- /dev/null +++ b/apps/yuudachi/src/functions/settings/updateGuildSetting.ts @@ -0,0 +1,22 @@ +import { kSQL } from "@yuudachi/framework"; +import type { Snowflake } from "discord.js"; +import type { SerializableParameter, Sql } from "postgres"; +import { container } from "tsyringe"; +import type { SettingsKeys, ReportStatusTagTuple, ReportTypeTagTuple } from "./getGuildSetting.js"; + +export async function updateGuildSetting( + guildId: Snowflake, + prop: SettingsKeys, + newValue: T, + table = "guild_settings", +) { + const sql = container.resolve>(kSQL); + + const [data] = await sql.unsafe<[{ value: ReportStatusTagTuple | ReportTypeTagTuple | boolean | string | null }?]>( + `update ${table} set ${prop} = $2 + where guild_id = $1`, + [guildId, newValue] as SerializableParameter[], + ); + + return (data?.value ?? null) as unknown as T; +} diff --git a/apps/yuudachi/src/interactions/moderation/reports.ts b/apps/yuudachi/src/interactions/moderation/reports.ts index a0d5326fe..4b5e512c0 100644 --- a/apps/yuudachi/src/interactions/moderation/reports.ts +++ b/apps/yuudachi/src/interactions/moderation/reports.ts @@ -49,6 +49,29 @@ export const ReportUtilsCommand = { }, ], }, + { + name: "update-restriction-level", + description: "Update the restriction level all the future reports will have", + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: "level", + description: "The new restriction level", + type: ApplicationCommandOptionType.String, + choices: [ + { name: "Enabled", value: "enabled" }, + { name: "Restricted (require confirmation)", value: "restricted" }, + { name: "Blocked", value: "blocked" }, + ], + required: true, + }, + { + name: "reason", + description: "The reason that will be shown to the user on restricted or blocked report attempts", + type: ApplicationCommandOptionType.String, + }, + ], + }, ], default_member_permissions: "0", } as const;