feat: implement reporting system
This commit is contained in:
270
apps/discord-bot/src/reporting.ts
Normal file
270
apps/discord-bot/src/reporting.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, ComponentType,
|
||||
EmbedBuilder, FileUploadBuilder, LabelBuilder, MessageFlags, ModalBuilder, TextInputBuilder, TextInputStyle,
|
||||
type ButtonInteraction, type Client, type FileUploadModalData, type ModalSubmitInteraction, type TextChannel,
|
||||
} from "discord.js";
|
||||
import { createLogger } from "@lbf-bot/utils";
|
||||
import { db, tables, eq } from "@lbf-bot/database";
|
||||
import { env } from "~/env";
|
||||
import { searchPlayer } from "~/wov";
|
||||
|
||||
const logger = createLogger({ prefix: "reporting" });
|
||||
|
||||
export const REPORT_BUTTON_ID = "report:open";
|
||||
export const REPORT_MODAL_ID = "report:modal";
|
||||
export const REPORT_EDIT_BUTTON_PREFIX = "report:edit";
|
||||
export const REPORT_DELETE_BUTTON_PREFIX = "report:delete";
|
||||
export const REPORT_EDIT_MODAL_PREFIX = "report:edit:modal";
|
||||
|
||||
const buildReportEmbed = (report: { playerName: string; playerId: string; reason: string; reporterId: string }) =>
|
||||
new EmbedBuilder()
|
||||
.setDescription([
|
||||
`**Pseudo**: \`${report.playerName}\``,
|
||||
`**ID**: \`${report.playerId}\``,
|
||||
`**Raison**: \`\`\`${report.reason}\`\`\``,
|
||||
`-# Signalé par <@${report.reporterId}>`
|
||||
].join("\n"));
|
||||
|
||||
const reportActionRow = (reportId: string) =>
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`${REPORT_EDIT_BUTTON_PREFIX}:${reportId}`)
|
||||
.setLabel("Modifier")
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`${REPORT_DELETE_BUTTON_PREFIX}:${reportId}`)
|
||||
.setLabel("Supprimer")
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
);
|
||||
|
||||
const isAuthorized = (interaction: ButtonInteraction | ModalSubmitInteraction, reporterId: string) => {
|
||||
if (interaction.user.id === reporterId) return true;
|
||||
const { member } = interaction;
|
||||
if (!member) return false;
|
||||
const roles = member.roles;
|
||||
if (Array.isArray(roles)) return roles.includes(env.DISCORD_STAFF_ROLE_ID);
|
||||
return "cache" in roles && roles.cache.has(env.DISCORD_STAFF_ROLE_ID);
|
||||
};
|
||||
|
||||
const extractScreenshots = (fields: ModalSubmitInteraction["fields"]) => {
|
||||
const fileField = fields.fields.get("screenshots") as FileUploadModalData | undefined;
|
||||
const attachments = fileField?.type === ComponentType.FileUpload && fileField.attachments?.size > 0
|
||||
? [...fileField.attachments.values()]
|
||||
: null;
|
||||
const screenshotUrls = attachments ? attachments.map(a => a.url).join("\n") : null;
|
||||
return { attachments, screenshotUrls };
|
||||
};
|
||||
|
||||
const buildModal = () =>
|
||||
new ModalBuilder()
|
||||
.setCustomId(REPORT_MODAL_ID)
|
||||
.setTitle("Signaler un joueur")
|
||||
.addLabelComponents(
|
||||
new LabelBuilder()
|
||||
.setLabel("Pseudo du joueur (les majuscules comptent !)")
|
||||
.setTextInputComponent(
|
||||
new TextInputBuilder()
|
||||
.setCustomId("player_name")
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(true),
|
||||
),
|
||||
new LabelBuilder()
|
||||
.setLabel("Raison du signalement")
|
||||
.setTextInputComponent(
|
||||
new TextInputBuilder()
|
||||
.setCustomId("reason")
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setRequired(true),
|
||||
),
|
||||
new LabelBuilder()
|
||||
.setLabel("Screenshots (optionnel)")
|
||||
.setFileUploadComponent(
|
||||
new FileUploadBuilder()
|
||||
.setCustomId("screenshots")
|
||||
.setMaxValues(10)
|
||||
.setRequired(false),
|
||||
),
|
||||
);
|
||||
|
||||
const buildEditModal = (reportId: string, channelId: string, messageId: string, currentReason: string) =>
|
||||
new ModalBuilder()
|
||||
.setCustomId(`${REPORT_EDIT_MODAL_PREFIX}:${reportId}:${channelId}:${messageId}`)
|
||||
.setTitle("Modifier le signalement")
|
||||
.addLabelComponents(
|
||||
new LabelBuilder()
|
||||
.setLabel("Raison du signalement")
|
||||
.setTextInputComponent(
|
||||
new TextInputBuilder()
|
||||
.setCustomId("reason")
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setValue(currentReason)
|
||||
.setRequired(true),
|
||||
),
|
||||
new LabelBuilder()
|
||||
.setLabel("Screenshots (optionnel)")
|
||||
.setFileUploadComponent(
|
||||
new FileUploadBuilder()
|
||||
.setCustomId("screenshots")
|
||||
.setMaxValues(10)
|
||||
.setRequired(false),
|
||||
),
|
||||
);
|
||||
|
||||
const retryRow = () =>
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(REPORT_BUTTON_ID)
|
||||
.setLabel("Réessayer")
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
);
|
||||
|
||||
export const sendReportEmbed = async (channel: TextChannel) => {
|
||||
await channel.send({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setTitle("🚨 Signaler un joueur")
|
||||
.setDescription([
|
||||
"Tu as observé un comportement toxique ou de l'anti jeu ?",
|
||||
"",
|
||||
"Clique sur le bouton ci-dessous pour signaler un joueur.",
|
||||
"Pense à fournir le plus de détails dans 'raison', tu peux aussi ajouter des screenshots.",
|
||||
"",
|
||||
`-# Les signalements sont envoyés dans <#${env.DISCORD_REPORT_CHANNEL}>.`,
|
||||
].join("\n"))
|
||||
.setColor(0xe74c3c),
|
||||
],
|
||||
components: [
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(REPORT_BUTTON_ID)
|
||||
.setLabel("Signaler un joueur")
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
export const handleReportButton = async (interaction: ButtonInteraction) => {
|
||||
await interaction.showModal(buildModal());
|
||||
};
|
||||
|
||||
export const handleReportModal = async (interaction: ModalSubmitInteraction, client: Client) => {
|
||||
const playerName = interaction.fields.getTextInputValue("player_name");
|
||||
const reason = interaction.fields.getTextInputValue("reason");
|
||||
const { attachments, screenshotUrls } = extractScreenshots(interaction.fields);
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const player = await searchPlayer(playerName);
|
||||
if (!player) {
|
||||
await interaction.editReply({
|
||||
content: `Aucun joueur avec le pseudo **${playerName}** n'a été trouvé. Vérifie les majuscules et réessaie.`,
|
||||
components: [retryRow()],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(tables.reports)
|
||||
.values({
|
||||
reporterId: interaction.user.id,
|
||||
reporterUsername: interaction.user.username,
|
||||
playerName,
|
||||
playerId: player.id,
|
||||
reason,
|
||||
screenshots: screenshotUrls,
|
||||
})
|
||||
.returning({ id: tables.reports.id });
|
||||
|
||||
let messageLink = "";
|
||||
const reportChannel = await client.channels.fetch(env.DISCORD_REPORT_CHANNEL);
|
||||
if (reportChannel?.type === ChannelType.GuildText) {
|
||||
const reportMessage = await reportChannel.send({
|
||||
embeds: [buildReportEmbed({ playerName, playerId: player.id, reason, reporterId: interaction.user.id })],
|
||||
components: [reportActionRow(inserted.id)],
|
||||
});
|
||||
messageLink = `https://discord.com/channels/${reportChannel.guild.id}/${reportChannel.id}/${reportMessage.id}`;
|
||||
await db.update(tables.reports).set({ messageLink }).where(eq(tables.reports.id, inserted.id));
|
||||
if (attachments) {
|
||||
await reportChannel.send({ files: attachments.map(a => a.url) });
|
||||
}
|
||||
} else {
|
||||
logger.error("Invalid 'DISCORD_REPORT_CHANNEL'");
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
content: `Le joueur **${playerName}** a bien été signalé. Merci !${messageLink ? ` ${messageLink}` : ""}`,
|
||||
});
|
||||
};
|
||||
|
||||
export const handleEditButton = async (interaction: ButtonInteraction, reportId: string) => {
|
||||
const [report] = await db.select().from(tables.reports).where(eq(tables.reports.id, reportId));
|
||||
if (!report) {
|
||||
await interaction.reply({ content: "Signalement introuvable.", flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
if (!isAuthorized(interaction, report.reporterId)) {
|
||||
await interaction.reply({ content: "Tu n'as pas la permission de modifier ce signalement.", flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.showModal(buildEditModal(reportId, interaction.channelId, interaction.message.id, report.reason));
|
||||
};
|
||||
|
||||
export const handleDeleteButton = async (interaction: ButtonInteraction, reportId: string) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const [report] = await db.select().from(tables.reports).where(eq(tables.reports.id, reportId));
|
||||
if (!report) {
|
||||
await interaction.editReply({ content: "Signalement introuvable." });
|
||||
return;
|
||||
}
|
||||
if (!isAuthorized(interaction, report.reporterId)) {
|
||||
await interaction.editReply({ content: "Tu n'as pas la permission de supprimer ce signalement." });
|
||||
return;
|
||||
}
|
||||
|
||||
await db.delete(tables.reports).where(eq(tables.reports.id, reportId));
|
||||
await interaction.message.delete();
|
||||
await interaction.editReply({ content: "Signalement supprimé." });
|
||||
};
|
||||
|
||||
export const handleEditModal = async (interaction: ModalSubmitInteraction, client: Client, reportId: string, channelId: string, messageId: string) => {
|
||||
const reason = interaction.fields.getTextInputValue("reason");
|
||||
const { attachments, screenshotUrls } = extractScreenshots(interaction.fields);
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const [report] = await db.select().from(tables.reports).where(eq(tables.reports.id, reportId));
|
||||
if (!report) {
|
||||
await interaction.editReply({ content: "Signalement introuvable." });
|
||||
return;
|
||||
}
|
||||
if (!isAuthorized(interaction, report.reporterId)) {
|
||||
await interaction.editReply({ content: "Tu n'as pas la permission de modifier ce signalement." });
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData: { reason: string; screenshots?: string | null } = { reason };
|
||||
if (screenshotUrls !== null) updateData.screenshots = screenshotUrls;
|
||||
|
||||
await db.update(tables.reports).set(updateData).where(eq(tables.reports.id, reportId));
|
||||
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (channel?.type === ChannelType.GuildText) {
|
||||
try {
|
||||
const message = await channel.messages.fetch(messageId);
|
||||
await message.edit({
|
||||
embeds: [buildReportEmbed({ ...report, reason })],
|
||||
components: [reportActionRow(reportId)],
|
||||
});
|
||||
} catch {
|
||||
logger.error("Failed to fetch/edit report message");
|
||||
}
|
||||
if (attachments) {
|
||||
await channel.send({ files: attachments.map(a => a.url) });
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.editReply({ content: "Signalement modifié." });
|
||||
};
|
||||
Reference in New Issue
Block a user