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