feat: implement reporting system
Some checks failed
Build and Push Docker Image / typecheck (push) Failing after 24s
Build and Push Docker Image / build (push) Has been skipped

This commit is contained in:
2026-05-12 19:18:02 +02:00
parent f5a7dbf1e8
commit 00b3ade095
14 changed files with 888 additions and 44 deletions

View File

@@ -7,6 +7,8 @@ import { gemmesCommand } from "./gemmes";
import { resultCommand } from "./result";
import { queteCommand } from "./quete";
import { tgCommand } from "./tg";
import { reportmsgCommand } from "./reportmsg";
import { reportsCommand } from "./reports";
export type CommandHandler = (
message: OmitPartialGroupDMChannel<Message<boolean>>,
@@ -26,5 +28,7 @@ export const commands: Record<string, Command> = {
gemmes: gemmesCommand,
quete: queteCommand,
result: resultCommand,
tg: tgCommand,
tg: tgCommand,
reportmsg: reportmsgCommand,
reports: reportsCommand,
};

View File

@@ -0,0 +1,23 @@
import { ChannelType } from "discord.js";
import { sendReportEmbed } from "~/reporting";
import { replyError } from "~/discord";
import type { Command } from "./index";
import { env } from "~/env";
export const reportmsgCommand: Command = {
help: "Envoie le message de signalement dans ce salon",
handler: async (message) => {
if (!message.member?.roles.cache.has(env.DISCORD_STAFF_ROLE_ID)) {
await replyError(message, "Tu t'es cru chez mémé ou quoi faut être staff");
return;
}
if (message.channel.type !== ChannelType.GuildText) {
await replyError(message, "Cette commande doit être utilisée dans un salon texte.");
return;
}
await message.delete();
await sendReportEmbed(message.channel);
},
};

View File

@@ -0,0 +1,50 @@
import { EmbedBuilder } from "discord.js";
import { db, tables, eq } from "@lbf-bot/database";
import { noMention, replyError } from "~/discord";
import { searchPlayer } from "~/wov";
import type { Command } from "./index";
export const reportsCommand: Command = {
help: "Liste tous les signalements d'un joueur",
handler: async (message, args) => {
const username = args.join(" ");
if (!username) {
await replyError(message, "Usage: `lbf reports <pseudo>`");
return;
}
const player = await searchPlayer(username);
if (!player) {
await replyError(message, `Aucun joueur avec le pseudo **${username}** n'a été trouvé.`);
return;
}
const reports = await db
.select()
.from(tables.reports)
.where(eq(tables.reports.playerId, player.id))
.orderBy(tables.reports.createdAt);
if (reports.length === 0) {
await message.reply({ ...noMention, content: `Aucun signalement trouvé pour **${username}**.` });
return;
}
const lines = reports.map((r, i) => {
const date = r.createdAt.toLocaleDateString("fr-FR");
const reason = r.reason.length > 100 ? `${r.reason.slice(0, 100)}` : r.reason;
const link = r.messageLink ? ` - [voir](${r.messageLink})` : "";
return `**${i + 1}.** ${date} - \`${reason}\`${link}`;
});
await message.reply({
...noMention,
embeds: [
new EmbedBuilder()
.setTitle(`Signalements de ${username}`)
.setDescription(lines.join("\n"))
.setFooter({ text: `${reports.length} signalement${reports.length > 1 ? "s" : ""}` }),
],
});
},
};

View File

@@ -13,6 +13,7 @@ export const env = parseEnv({
// TODO: rename to reward ask channel or smth
DISCORD_ADMIN_CHANNEL: z.string(),
DISCORD_TRACKING_CHANNEL: z.string(),
DISCORD_REPORT_CHANNEL: z.string(),
DISCORD_STAFF_ROLE_ID: z.string(),
COMMIT_SHA: z.string().default("dev"),
WOV_API_KEY: z.string(),

View File

@@ -4,6 +4,11 @@ import { env } from "~/env";
import { questCheckCron } from "~/quests";
import { trackingCron } from "~/tracking";
import { commands } from "~/commands";
import {
handleReportButton, handleReportModal, handleEditButton, handleDeleteButton, handleEditModal,
REPORT_BUTTON_ID, REPORT_MODAL_ID,
REPORT_EDIT_BUTTON_PREFIX, REPORT_DELETE_BUTTON_PREFIX, REPORT_EDIT_MODAL_PREFIX,
} from "~/reporting";
const onReady = async (client: Client<true>) => {
logger.info(`Client ready`);
@@ -37,7 +42,30 @@ const onMessage = async (message: OmitPartialGroupDMChannel<Message>) => {
}
};
export const setupBotMode = (client: Client) => {
client.on("clientReady", (client) => { void onReady(client); });
client.on("messageCreate", (message) => { void onMessage(message); });
const onInteraction = async (interaction: Parameters<Parameters<Client["on"]>[1]>[0], client: Client) => {
if (interaction.isButton()) {
if (interaction.customId === REPORT_BUTTON_ID) {
await handleReportButton(interaction);
} else if (interaction.customId.startsWith(`${REPORT_EDIT_BUTTON_PREFIX}:`)) {
const reportId = interaction.customId.slice(REPORT_EDIT_BUTTON_PREFIX.length + 1);
await handleEditButton(interaction, reportId);
} else if (interaction.customId.startsWith(`${REPORT_DELETE_BUTTON_PREFIX}:`)) {
const reportId = interaction.customId.slice(REPORT_DELETE_BUTTON_PREFIX.length + 1);
await handleDeleteButton(interaction, reportId);
}
} else if (interaction.isModalSubmit()) {
if (interaction.customId === REPORT_MODAL_ID) {
await handleReportModal(interaction, client);
} else if (interaction.customId.startsWith(`${REPORT_EDIT_MODAL_PREFIX}:`)) {
const rest = interaction.customId.slice(REPORT_EDIT_MODAL_PREFIX.length + 1);
const [reportId, channelId, messageId] = rest.split(":");
await handleEditModal(interaction, client, reportId, channelId, messageId);
}
}
};
export const setupBotMode = (client: Client) => {
client.on("clientReady", (client) => { void onReady(client); });
client.on("messageCreate", (message) => { void onMessage(message); });
client.on("interactionCreate", (interaction) => { void onInteraction(interaction, client); });
};

View 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é." });
};