Files
lbf-bot/apps/discord-bot/src/reporting.ts
Pihkaal 4059ea1ddf
All checks were successful
Build and Deploy / typecheck (push) Successful in 27s
Build and Deploy / build (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 2s
chore(discord-bot): remove all migration related code
2026-05-30 22:13:13 +02:00

300 lines
12 KiB
TypeScript

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 formatDate = (date: Date): string => {
const d = String(date.getDate()).padStart(2, "0");
const m = String(date.getMonth() + 1).padStart(2, "0");
return `${d}/${m}/${date.getFullYear()}`;
};
const buildReportEmbed = (report: { playerName: string; playerId: string; reason: string; reporterId: string; createdAt: Date }) =>
new EmbedBuilder()
.setDescription([
`**Pseudo**: \`${report.playerName}\``,
`**ID**: \`${report.playerId}\``,
`**Date**: \`${formatDate(report.createdAt)}\``,
`**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,
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({
content: "─────────────────────────────────",
embeds: [buildReportEmbed({ playerName, playerId: player.id, reason, reporterId: interaction.user.id, createdAt: new Date() })],
components: [reportActionRow(inserted.id)],
});
messageLink = `https://discord.com/channels/${reportChannel.guild.id}/${reportChannel.id}/${reportMessage.id}`;
const screenshotsMessage = attachments
? await reportChannel.send({ files: attachments.map(a => a.url) })
: await reportChannel.send({ content: "-# Pas de screenshots" });
await db.update(tables.reports)
.set({ messageLink, screenshotsMessageId: screenshotsMessage.id })
.where(eq(tables.reports.id, inserted.id));
} 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));
if (report.screenshotsMessageId) {
try {
const screenshotsMsg = await interaction.message.channel.messages.fetch(report.screenshotsMessageId);
await screenshotsMsg.delete();
} catch {
// already deleted or not found
}
}
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 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 (report.screenshotsMessageId) {
try {
const screenshotsMsg = await channel.messages.fetch(report.screenshotsMessageId);
if (attachments) {
await screenshotsMsg.edit({ content: "", files: attachments.map(a => a.url), attachments: [] });
} else {
await screenshotsMsg.edit({ content: "-# Pas de screenshots", attachments: [] });
}
} catch {
logger.error("Failed to edit screenshots message");
}
}
}
await db.update(tables.reports)
.set({ reason, screenshots: screenshotUrls })
.where(eq(tables.reports.id, reportId));
await interaction.editReply({ content: "Signalement modifié." });
};