Skip to content

Commit

Permalink
feat(reports): ability to restrict reports server wide
Browse files Browse the repository at this point in the history
  • Loading branch information
JPBM135 committed Sep 17, 2024
1 parent e398023 commit c9de5d7
Show file tree
Hide file tree
Showing 10 changed files with 328 additions and 22 deletions.
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 28 additions & 4 deletions apps/yuudachi/locales/en-US/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
},
Expand Down Expand Up @@ -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": {
Expand All @@ -788,4 +812,4 @@
}
}
}
}
}
17 changes: 17 additions & 0 deletions apps/yuudachi/migrations/1673411947-reports-restriction-level.js
Original file line number Diff line number Diff line change
@@ -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';
`);
}
6 changes: 6 additions & 0 deletions apps/yuudachi/src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
194 changes: 178 additions & 16 deletions apps/yuudachi/src/commands/moderation/report.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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) {
Expand Down Expand Up @@ -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 || "" : ""));
Expand All @@ -155,6 +214,7 @@ export default class extends Command<
reason: reason.join(" "),
},
locale,
pendingReport,
);
}

Expand All @@ -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,
Expand Down Expand Up @@ -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 || "" : ""));
Expand All @@ -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<boolean> {
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<string>(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<boolean>,
): Promise<Report | null | undefined> {
): Promise<{
pendingReport: Report | null;
restrictionLevel: ReportsRestrictionLevel;
}> {
if (target.bot) {
throw new Error(i18next.t("command.mod.report.common.errors.bot", { lng: locale }));
}
Expand All @@ -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<ReportsRestrictionLevel>(
author.guild.id,
SettingsKeys.ReportsRestrictionLevel,
);

if (restrictionLevel === ReportsRestrictionLevel.Blocked) {
const reason = await getGuildSetting<string>(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))) {
Expand All @@ -260,6 +419,9 @@ export default class extends Command<
}
}

return latestReport;
return {
pendingReport: latestReport,
restrictionLevel,
};
}
}
Loading

0 comments on commit c9de5d7

Please sign in to comment.