From 943d69472c28f17b190859980b739dc49ab894c7 Mon Sep 17 00:00:00 2001 From: Pihkaal Date: Sat, 30 May 2026 21:15:15 +0200 Subject: [PATCH] feat(discord-bot): REPORTS_JSON for reports migration --- apps/discord-bot/src/env.ts | 1 + apps/discord-bot/src/modes/bot.ts | 4 +- apps/discord-bot/src/reporting.ts | 104 ++++++++- docker-compose.yml | 1 + .../drizzle/0004_curved_imperial_guard.sql | 1 + .../database/drizzle/meta/0004_snapshot.json | 214 ++++++++++++++++++ packages/database/drizzle/meta/_journal.json | 7 + packages/database/src/schema/tables.ts | 3 +- 8 files changed, 329 insertions(+), 6 deletions(-) create mode 100644 packages/database/drizzle/0004_curved_imperial_guard.sql create mode 100644 packages/database/drizzle/meta/0004_snapshot.json diff --git a/apps/discord-bot/src/env.ts b/apps/discord-bot/src/env.ts index 9369330..82f4d4b 100644 --- a/apps/discord-bot/src/env.ts +++ b/apps/discord-bot/src/env.ts @@ -33,4 +33,5 @@ export const env = parseEnv({ .transform((x) => x.split(",").map((x) => x.trim())) .optional() .default([]), + REPORTS_JSON: z.string().optional(), }); diff --git a/apps/discord-bot/src/modes/bot.ts b/apps/discord-bot/src/modes/bot.ts index ee8ef3a..0a53b8f 100644 --- a/apps/discord-bot/src/modes/bot.ts +++ b/apps/discord-bot/src/modes/bot.ts @@ -4,12 +4,14 @@ 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"; +import { handleReportButton, handleReportModal, handleEditButton, handleDeleteButton, handleEditModal, importReports, 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) => { logger.info(`Client ready`); logger.info(`Connected as @${client.user.username}`); + await importReports(client); + await questCheckCron(client); setInterval(() => void questCheckCron(client), env.WOV_FETCH_INTERVAL); diff --git a/apps/discord-bot/src/reporting.ts b/apps/discord-bot/src/reporting.ts index d5a103d..efd733f 100644 --- a/apps/discord-bot/src/reporting.ts +++ b/apps/discord-bot/src/reporting.ts @@ -3,8 +3,9 @@ import { EmbedBuilder, FileUploadBuilder, LabelBuilder, MessageFlags, ModalBuilder, TextInputBuilder, TextInputStyle, type ButtonInteraction, type Client, type FileUploadModalData, type ModalSubmitInteraction, type TextChannel, } from "discord.js"; +import { z } from "zod"; import { createLogger } from "@lbf-bot/utils"; -import { db, tables, eq } from "@lbf-bot/database"; +import { db, tables, eq, and } from "@lbf-bot/database"; import { env } from "~/env"; import { searchPlayer } from "~/wov"; @@ -167,8 +168,7 @@ export const handleReportModal = async (interaction: ModalSubmitInteraction, cli const [inserted] = await db .insert(tables.reports) .values({ - reporterId: interaction.user.id, - reporterUsername: interaction.user.username, + reporterId: interaction.user.id, playerName, playerId: player.id, reason, @@ -242,6 +242,104 @@ export const handleDeleteButton = async (interaction: ButtonInteraction, reportI await interaction.editReply({ content: "Signalement supprimé." }); }; +const importEntrySchema = z.object({ + username: z.string(), + author_id: z.string(), + reason: z.string(), + date: z.string(), +}); + +const parseImportDate = (date: string): Date => { + const [month, day, year] = date.split("/").map(Number); + return new Date(2000 + year, month - 1, day); +}; + +export const importReports = async (client: Client) => { + if (!env.REPORTS_JSON) return; + + let rawJson: unknown; + try { + rawJson = JSON.parse(env.REPORTS_JSON); + } catch { + logger.error("REPORTS_JSON is not valid JSON"); + return; + } + + const parsed = z.array(importEntrySchema).safeParse(rawJson); + if (!parsed.success) { + logger.error("Invalid REPORTS_JSON:", parsed.error.issues); + return; + } + + const reportChannel = await client.channels.fetch(env.DISCORD_REPORT_CHANNEL); + if (reportChannel?.type !== ChannelType.GuildText) { + logger.error("Invalid 'DISCORD_REPORT_CHANNEL' for import"); + return; + } + + let imported = 0; + let skipped = 0; + let failed = 0; + + for (const entry of parsed.data) { + const player = await searchPlayer(entry.username); + if (!player) { + logger.warn(`Import: player not found, skipping — ${entry.username}`); + failed++; + continue; + } + + const existing = await db.query.reports.findFirst({ + columns: { id: true }, + where: and(eq(tables.reports.playerId, player.id), eq(tables.reports.reason, entry.reason)), + }); + + if (existing) { + skipped++; + continue; + } + + try { + const [inserted] = await db + .insert(tables.reports) + .values({ + reporterId: entry.author_id, + playerName: entry.username, + playerId: player.id, + reason: entry.reason, + createdAt: parseImportDate(entry.date), + }) + .returning({ id: tables.reports.id }); + + const reportMessage = await reportChannel.send({ + content: "─────────────────────────────────", + embeds: [buildReportEmbed({ playerName: entry.username, playerId: player.id, reason: entry.reason, reporterId: entry.author_id })], + components: [reportActionRow(inserted.id)], + }); + + const messageLink = `https://discord.com/channels/${reportChannel.guild.id}/${reportChannel.id}/${reportMessage.id}`; + const screenshotsMessage = await reportChannel.send({ content: "-# Pas de screenshots" }); + + await db.update(tables.reports) + .set({ messageLink, screenshotsMessageId: screenshotsMessage.id }) + .where(eq(tables.reports.id, inserted.id)); + + imported++; + } catch (error) { + logger.error(`Import: failed for ${entry.username}:`, error); + failed++; + } + + // avoid spamming too much the APIs + await new Promise(resolve => setTimeout(resolve, 500)); + if (imported > 0 && imported % 50 === 0) { + await new Promise(resolve => setTimeout(resolve, 60000)); + } + } + + logger.info(`Import complete: ${imported} imported, ${skipped} skipped, ${failed} not found`); +}; + 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); diff --git a/docker-compose.yml b/docker-compose.yml index 4ac9e33..13b936f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,6 +58,7 @@ services: - QUEST_REWARDS - QUEST_REWARDS_ARE_GEMS - QUEST_EXCLUDE + - REPORTS_JSON networks: lbf-network: diff --git a/packages/database/drizzle/0004_curved_imperial_guard.sql b/packages/database/drizzle/0004_curved_imperial_guard.sql new file mode 100644 index 0000000..4ccf93e --- /dev/null +++ b/packages/database/drizzle/0004_curved_imperial_guard.sql @@ -0,0 +1 @@ +ALTER TABLE "reports" DROP COLUMN "reporter_username"; \ No newline at end of file diff --git a/packages/database/drizzle/meta/0004_snapshot.json b/packages/database/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..6ccb50e --- /dev/null +++ b/packages/database/drizzle/meta/0004_snapshot.json @@ -0,0 +1,214 @@ +{ + "id": "11a9aa46-c570-4372-aa71-c5a576a930f4", + "prevId": "a19383ff-8e7a-4da4-8c29-888e8ea9f670", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "player_id": { + "name": "player_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "player_name": { + "name": "player_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "screenshots": { + "name": "screenshots", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_link": { + "name": "message_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "screenshots_message_id": { + "name": "screenshots_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_players": { + "name": "tracked_players", + "schema": "", + "columns": { + "player_id": { + "name": "player_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.username_history": { + "name": "username_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "player_id": { + "name": "player_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "username_history_player_id_tracked_players_player_id_fk": { + "name": "username_history_player_id_tracked_players_player_id_fk", + "tableFrom": "username_history", + "tableTo": "tracked_players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "player_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index 24eb97c..c12e544 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1778607030657, "tag": "0003_uneven_mephistopheles", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1780166865311, + "tag": "0004_curved_imperial_guard", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema/tables.ts b/packages/database/src/schema/tables.ts index ae47990..2b874ca 100644 --- a/packages/database/src/schema/tables.ts +++ b/packages/database/src/schema/tables.ts @@ -5,8 +5,7 @@ import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; */ export const reports = pgTable("reports", { id: uuid("id").primaryKey().defaultRandom(), - reporterId: text("reporter_id").notNull(), - reporterUsername: text("reporter_username").notNull(), + reporterId: text("reporter_id").notNull(), playerName: text("player_name").notNull(), playerId: text("player_id").notNull(), reason: text("reason").notNull(),