diff --git a/commands/server/server-clean-keep.js b/commands/server/server-clean-keep.js deleted file mode 100644 index db6873d..0000000 --- a/commands/server/server-clean-keep.js +++ /dev/null @@ -1,247 +0,0 @@ -const { SlashCommandBuilder, MessageFlags } = require('discord.js'); -const fs = require('node:fs/promises'); -const path = require('node:path'); - -const ROLE_RYGAINLAND = '1444684935632912394'; -const KEEP_LIST_FILE_PATH = path.resolve(__dirname, 'server-clean-keep.json'); - -const normUid32 = (val) => { - if (!val) return ''; - const s = String(val).replace(/-/g, '').trim().toLowerCase(); - if (s.length !== 32) return ''; - if (!/^[0-9a-f]{32}$/.test(s)) return ''; - return s; -}; - -async function readKeepList() { - try { - const raw = await fs.readFile(KEEP_LIST_FILE_PATH, 'utf8'); - const parsed = JSON.parse(raw); - - const discordIds = Array.isArray(parsed.discordIds) - ? parsed.discordIds.map((x) => String(x || '').trim()).filter(Boolean) - : []; - - const playerIds = Array.isArray(parsed.playerIds) - ? parsed.playerIds.map(normUid32).filter(Boolean) - : []; - - return { - discordIds: [...new Set(discordIds)], - playerIds: [...new Set(playerIds)], - }; - } catch (error) { - if (error && error.code === 'ENOENT') { - return { discordIds: [], playerIds: [] }; - } - throw error; - } -} - -async function writeKeepList(keepList) { - const payload = { - discordIds: [...new Set((keepList.discordIds || []).map((x) => String(x || '').trim()).filter(Boolean))], - playerIds: [...new Set((keepList.playerIds || []).map(normUid32).filter(Boolean))], - }; - - await fs.writeFile(KEEP_LIST_FILE_PATH, JSON.stringify(payload, null, 2), 'utf8'); -} - -module.exports = { - data: new SlashCommandBuilder() - .setName('server-clean-keep') - .setDescription('Gérer la liste blanche des joueurs protégés du /server-clean') - .addSubcommand((sub) => - sub - .setName('list') - .setDescription('Afficher la liste blanche actuelle') - ) - .addSubcommand((sub) => - sub - .setName('add-discord') - .setDescription('Ajouter un compte Discord à la liste blanche') - .addUserOption((opt) => - opt - .setName('membre') - .setDescription('Compte Discord à protéger') - .setRequired(true) - ) - ) - .addSubcommand((sub) => - sub - .setName('remove-discord') - .setDescription('Retirer un compte Discord de la liste blanche') - .addUserOption((opt) => - opt - .setName('membre') - .setDescription('Compte Discord à retirer') - .setRequired(true) - ) - ) - .addSubcommand((sub) => - sub - .setName('add-player') - .setDescription('Ajouter un Player ID Palworld à la liste blanche') - .addStringOption((opt) => - opt - .setName('player-id') - .setDescription('UID hexadécimal (32 caractères)') - .setRequired(true) - ) - ) - .addSubcommand((sub) => - sub - .setName('remove-player') - .setDescription('Retirer un Player ID Palworld de la liste blanche') - .addStringOption((opt) => - opt - .setName('player-id') - .setDescription('UID hexadécimal (32 caractères)') - .setRequired(true) - ) - ), - - async execute(interaction) { - if (!interaction.member.roles.cache.has(ROLE_RYGAINLAND)) { - await interaction.reply({ - content: '❌ Il faut avoir le rôle Rygainland pour utiliser cette commande.', - flags: MessageFlags.Ephemeral, - }); - return; - } - - const sub = interaction.options.getSubcommand(); - - try { - const keepList = await readKeepList(); - - if (sub === 'list') { - const discordText = keepList.discordIds.length - ? keepList.discordIds.map((id) => `- <@${id}> (${id})`).join('\n') - : '- (vide)'; - - const playerText = keepList.playerIds.length - ? keepList.playerIds.map((id) => `- ${id}`).join('\n') - : '- (vide)'; - - await interaction.reply({ - content: - '🛡️ Liste blanche /server-clean\n\n' + - `Discord IDs (${keepList.discordIds.length})\n${discordText}\n\n` + - `Player IDs (${keepList.playerIds.length})\n${playerText}`, - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (sub === 'add-discord') { - const user = interaction.options.getUser('membre', true); - if (!keepList.discordIds.includes(user.id)) { - keepList.discordIds.push(user.id); - await writeKeepList(keepList); - await interaction.reply({ - content: `✅ Ajouté à la liste blanche Discord: <@${user.id}> (${user.id})`, - flags: MessageFlags.Ephemeral, - }); - return; - } - - await interaction.reply({ - content: `ℹ️ Déjà présent dans la liste blanche Discord: <@${user.id}>`, - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (sub === 'remove-discord') { - const user = interaction.options.getUser('membre', true); - const before = keepList.discordIds.length; - keepList.discordIds = keepList.discordIds.filter((id) => id !== user.id); - - if (keepList.discordIds.length !== before) { - await writeKeepList(keepList); - await interaction.reply({ - content: `✅ Retiré de la liste blanche Discord: <@${user.id}> (${user.id})`, - flags: MessageFlags.Ephemeral, - }); - return; - } - - await interaction.reply({ - content: `ℹ️ Ce compte n'était pas dans la liste blanche Discord: <@${user.id}>`, - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (sub === 'add-player') { - const raw = interaction.options.getString('player-id', true); - const uid = normUid32(raw); - if (!uid) { - await interaction.reply({ - content: '❌ Player ID invalide. Format attendu: 32 caractères hexadécimaux.', - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (!keepList.playerIds.includes(uid)) { - keepList.playerIds.push(uid); - await writeKeepList(keepList); - await interaction.reply({ - content: `✅ Player ID ajouté à la liste blanche: ${uid}`, - flags: MessageFlags.Ephemeral, - }); - return; - } - - await interaction.reply({ - content: `ℹ️ Player ID déjà présent dans la liste blanche: ${uid}`, - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (sub === 'remove-player') { - const raw = interaction.options.getString('player-id', true); - const uid = normUid32(raw); - if (!uid) { - await interaction.reply({ - content: '❌ Player ID invalide. Format attendu: 32 caractères hexadécimaux.', - flags: MessageFlags.Ephemeral, - }); - return; - } - - const before = keepList.playerIds.length; - keepList.playerIds = keepList.playerIds.filter((id) => id !== uid); - - if (keepList.playerIds.length !== before) { - await writeKeepList(keepList); - await interaction.reply({ - content: `✅ Player ID retiré de la liste blanche: ${uid}`, - flags: MessageFlags.Ephemeral, - }); - return; - } - - await interaction.reply({ - content: `ℹ️ Player ID non trouvé dans la liste blanche: ${uid}`, - flags: MessageFlags.Ephemeral, - }); - return; - } - - await interaction.reply({ - content: '❌ Sous-commande inconnue.', - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - console.error('Erreur server-clean-keep:', error); - await interaction.reply({ - content: `❌ Erreur: ${error.message || String(error)}`, - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }, -}; diff --git a/commands/server/server-clean-keep.json b/commands/server/server-clean-keep.json deleted file mode 100644 index 1c42c3a..0000000 --- a/commands/server/server-clean-keep.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "discordIds": [], - "playerIds": [] -} diff --git a/commands/server/server-clean.js b/commands/server/server-clean.js deleted file mode 100644 index daf7956..0000000 --- a/commands/server/server-clean.js +++ /dev/null @@ -1,390 +0,0 @@ -const { SlashCommandBuilder } = require('discord.js'); -const axios = require('axios'); -const os = require('node:os'); -const fs = require('node:fs/promises'); -const path = require('node:path'); -const { execFile } = require('node:child_process'); -const { promisify } = require('node:util'); - -const { getAllLinks } = require('../../src/core/database.js'); -const { - defaultBaseUrl, - defaultServerId, - listDirectory, - downloadFile, - uploadSingleFile, - deleteFiles, -} = require('../../src/pterodactyl/pterodactylFiles.js'); - -const execFileAsync = promisify(execFile); - -const ROLE_RYGAINLAND = '1444684935632912394'; - -const keepListFilePath = path.resolve(__dirname, 'server-clean-keep.json'); - -const normUid32 = (val) => { - if (!val) return ''; - const s = String(val).replace(/-/g, '').trim().toLowerCase(); - if (s.length !== 32) return ''; - if (!/^[0-9a-f]{32}$/.test(s)) return ''; - return s; -}; - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -const parseCsv = (val) => - String(val || '') - .split(',') - .map((x) => x.trim()) - .filter(Boolean); - -async function loadKeepList() { - const keepDiscordIds = new Set(parseCsv(process.env.SERVER_CLEAN_KEEP_DISCORD_IDS)); - const keepPlayerIds = new Set(parseCsv(process.env.SERVER_CLEAN_KEEP_PLAYER_IDS).map(normUid32).filter(Boolean)); - - try { - const raw = await fs.readFile(keepListFilePath, 'utf8'); - const parsed = JSON.parse(raw); - - if (Array.isArray(parsed.discordIds)) { - for (const id of parsed.discordIds) { - const clean = String(id || '').trim(); - if (clean) keepDiscordIds.add(clean); - } - } - - if (Array.isArray(parsed.playerIds)) { - for (const uid of parsed.playerIds) { - const clean = normUid32(uid); - if (clean) keepPlayerIds.add(clean); - } - } - } catch (error) { - if (error && error.code !== 'ENOENT') { - throw new Error(`Impossible de lire ${path.basename(keepListFilePath)}: ${error.message || String(error)}`); - } - } - - return { - keepDiscordIds, - keepPlayerIds, - }; -} - -async function waitForServerState({ headers, baseUrl, serverId, wanted, timeoutMs }) { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - const status = await axios.get(`${baseUrl}/api/client/servers/${serverId}/resources`, { headers }); - const state = status?.data?.attributes?.current_state; - if (state === wanted) return true; - await sleep(5000); - } - return false; -} - -async function detectWorldId({ headers, baseUrl, serverId, saveRoot }) { - const envWorldId = (process.env.PALWORLD_WORLD_ID || '').trim(); - if (envWorldId) return envWorldId; - - const items = await listDirectory({ headers, baseUrl, serverId, directory: saveRoot }); - const dirs = items.filter((x) => x && x.is_file === false && typeof x.name === 'string'); - - if (dirs.length === 1) return dirs[0].name; - - const names = dirs.map((d) => d.name).slice(0, 10).join(', '); - throw new Error( - `Impossible de déterminer automatiquement le World ID. Définis PALWORLD_WORLD_ID dans .env. Dossiers trouvés dans ${saveRoot}: ${names || '(aucun)'}` - ); -} - -async function runPlayersDeleter({ levelSavPath, uids }) { - const execPath = (process.env.PLAYERS_DELETER_PATH || '').trim() || path.resolve(__dirname, '../../players-deleter'); - - // The shipped binary is Linux. If you run the bot on Windows, provide an alternate command. - if (process.platform === 'win32') { - throw new Error( - "players-deleter est un exécutable Linux. Lance le bot sous Linux/WSL, ou définis PLAYERS_DELETER_PATH vers une commande compatible (ex: script Python)." - ); - } - - const args = [ - '--level-sav-path', - levelSavPath, - '--no-backup', - '--keep-player-files', - ]; - - for (const uid of uids) { - args.push('--uid', uid); - } - - if ((process.env.PLAYERS_DELETER_ALLOW_ZLIB_FALLBACK || '').toLowerCase() === 'true') { - args.push('--allow-zlib-fallback'); - } - - // Best effort: ensure the binary has execute permission on Linux containers. - await fs.chmod(execPath, 0o755).catch(() => {}); - - let stdout = ''; - let stderr = ''; - try { - const result = await execFileAsync(execPath, args, { timeout: 10 * 60 * 1000 }); - stdout = result.stdout; - stderr = result.stderr; - } catch (error) { - if (error && error.code === 'EACCES') { - throw new Error( - `Impossible d'exécuter players-deleter (${execPath}): permission refusée (EACCES). ` + - `Vérifie que le fichier est exécutable (chmod +x) et que le filesystem n'est pas monté en noexec. ` + - `Tu peux aussi définir PLAYERS_DELETER_PATH vers une commande exécutable.` - ); - } - throw error; - } - - const out = String(stdout || '').trim(); - if (out) { - try { - return JSON.parse(out); - } catch { - // If not JSON, return raw. - return { ok: true, stdout: out, stderr: String(stderr || '').trim() }; - } - } - - if (stderr) { - return { ok: true, stdout: '', stderr: String(stderr).trim() }; - } - - return { ok: true }; -} - -module.exports = { - data: new SlashCommandBuilder() - .setName('server-clean') - .setDescription('Supprime les joueurs inactifs (3 mois) ou absents du Discord, puis redéploie la sauvegarde'), - - async execute(interaction, headers) { - if (!interaction.member.roles.cache.has(ROLE_RYGAINLAND)) { - await interaction.reply({ - content: '❌ Il faut avoir le rôle Rygainland pour pouvoir utiliser cette commande.', - flags: 64, - }); - return; - } - - await interaction.deferReply(); - - const baseUrl = defaultBaseUrl(); - const serverId = defaultServerId(); - const apiHeaders = headers; - - try { - await interaction.editReply('🔎 Recherche des joueurs inactifs...'); - - const links = await getAllLinks(); - - const threeMonthsAgo = new Date(); - threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); - - const targetLinks = []; - const reasonsByDiscordId = new Map(); - - for (const link of links) { - const discordId = String(link.discord_id); - const member = await interaction.guild.members.fetch(discordId).catch(() => null); - - const reasons = []; - if (!member) { - reasons.push('absent_discord'); - } - - if (link.lastConnection) { - const lastConn = new Date(link.lastConnection); - if (lastConn < threeMonthsAgo) { - reasons.push('inactif_3_mois'); - } - } - - if (reasons.length > 0) { - targetLinks.push(link); - reasonsByDiscordId.set(discordId, reasons); - } - } - - // De-dupe by discord_id. - const byDiscord = new Map(); - for (const l of targetLinks) byDiscord.set(String(l.discord_id), l); - const deduped = [...byDiscord.values()]; - - const keepList = await loadKeepList(); - const filteredLinks = []; - let keepListExcludedCount = 0; - - for (const link of deduped) { - const discordId = String(link.discord_id || '').trim(); - const uid = normUid32(link.player_id); - - if (keepList.keepDiscordIds.has(discordId) || (uid && keepList.keepPlayerIds.has(uid))) { - keepListExcludedCount += 1; - continue; - } - - filteredLinks.push(link); - } - - const uidsToDelete = []; - const missingPlayerId = []; - - for (const link of filteredLinks) { - const uid = normUid32(link.player_id); - if (!uid) { - missingPlayerId.push(link); - continue; - } - uidsToDelete.push(uid); - } - - if (uidsToDelete.length === 0) { - const extra = missingPlayerId.length - ? `\n⚠️ ${missingPlayerId.length} membre(s) ciblé(s) sans Player ID en DB (impossible à supprimer automatiquement).` - : ''; - const keepText = keepListExcludedCount - ? `\n🛡️ Exclus via liste blanche: ${keepListExcludedCount}` - : ''; - await interaction.editReply(`✅ Aucun joueur supprimable trouvé.${extra}${keepText}`); - return; - } - - await interaction.editReply( - `🧹 Joueurs ciblés: **${uidsToDelete.length}** (inactifs/absents).\n` + - `💾 Sauvegarde serveur...` - ); - - // Check current state. - const statusResponse = await axios.get(`${baseUrl}/api/client/servers/${serverId}/resources`, { headers: apiHeaders }); - const currentState = statusResponse.data.attributes.current_state; - - if (currentState === 'running') { - await axios.post( - `${baseUrl}/api/client/servers/${serverId}/command`, - { command: 'save' }, - { headers: apiHeaders } - ); - await axios - .post( - `${baseUrl}/api/client/servers/${serverId}/command`, - { command: "broadcast 'Nettoyage des joueurs inactifs (arrêt serveur) - ~60s'" }, - { headers: apiHeaders } - ) - .catch(() => {}); - - await sleep(3000); - - await interaction.editReply('⏹️ Arrêt du serveur...'); - await axios.post( - `${baseUrl}/api/client/servers/${serverId}/power`, - { signal: 'stop' }, - { headers: apiHeaders } - ); - - const stopped = await waitForServerState({ - headers: apiHeaders, - baseUrl, - serverId, - wanted: 'offline', - timeoutMs: 3 * 60 * 1000, - }); - if (!stopped) { - await interaction.editReply('⚠️ Le serveur ne passe pas offline, tentative de continuer quand même...'); - } - } else { - await interaction.editReply('ℹ️ Serveur déjà arrêté. Passage à la récupération de la sauvegarde...'); - } - - const saveRoot = (process.env.PALWORLD_SAVE_ROOT || '/Pal/Saved/SaveGames/0').trim(); - const worldId = await detectWorldId({ headers: apiHeaders, baseUrl, serverId, saveRoot }); - const worldDir = `${saveRoot}/${worldId}`; - const levelSavRemote = `${worldDir}/Level.sav`; - - await interaction.editReply(`📥 Téléchargement de Level.sav (World: ${worldId})...`); - const levelSavBuf = await downloadFile({ headers: apiHeaders, baseUrl, serverId, file: levelSavRemote }); - - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'server-clean-')); - const localLevelSav = path.join(tmpDir, 'Level.sav'); - await fs.writeFile(localLevelSav, levelSavBuf); - - await interaction.editReply('🧬 Suppression des joueurs inactifs dans la sauvegarde...'); - const deleterReport = await runPlayersDeleter({ levelSavPath: localLevelSav, uids: uidsToDelete }); - - const newLevelSavBuf = await fs.readFile(localLevelSav); - - await interaction.editReply('📤 Upload de Level.sav modifié...'); - await uploadSingleFile({ - headers: apiHeaders, - baseUrl, - serverId, - directory: worldDir, - filename: 'Level.sav', - content: newLevelSavBuf, - }); - - await interaction.editReply('🗑️ Suppression des fichiers Players/.sav...'); - const playersDir = `${worldDir}/Players`; - - const filesToDelete = []; - for (const uid of uidsToDelete) { - filesToDelete.push(`${uid}.sav`); - filesToDelete.push(`${uid}_dps.sav`); - } - - // Chunk deletes to keep requests reasonable. - const CHUNK = 100; - for (let i = 0; i < filesToDelete.length; i += CHUNK) { - const slice = filesToDelete.slice(i, i + CHUNK); - await deleteFiles({ headers: apiHeaders, baseUrl, serverId, root: playersDir, files: slice }).catch(() => {}); - } - - await interaction.editReply('🚀 Démarrage du serveur...'); - await axios.post( - `${baseUrl}/api/client/servers/${serverId}/power`, - { signal: 'start' }, - { headers: apiHeaders } - ); - - const running = await waitForServerState({ - headers: apiHeaders, - baseUrl, - serverId, - wanted: 'running', - timeoutMs: 4 * 60 * 1000, - }); - - const missingText = missingPlayerId.length - ? `\n⚠️ Non supprimés (pas de Player ID en DB): ${missingPlayerId.length}` - : ''; - - const keepText = keepListExcludedCount - ? `\n🛡️ Exclus via liste blanche: ${keepListExcludedCount}` - : ''; - - const okText = running ? '✅ Nettoyage terminé et serveur redémarré.' : '⚠️ Nettoyage terminé, mais le serveur n\'a pas confirmé son état running.'; - - // Keep output short; deleterReport can be long. - const reportSummary = deleterReport && typeof deleterReport === 'object' - ? JSON.stringify(deleterReport).slice(0, 900) - : ''; - - await interaction.editReply( - `${okText}\n` + - `👥 Joueurs supprimés (ciblés): ${uidsToDelete.length}${missingText}${keepText}\n` + - (reportSummary ? `📄 players-deleter: ${reportSummary}` : '') - ); - - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); - } catch (error) { - console.error('Erreur /server-clean:', error); - await interaction.editReply(`❌ Erreur pendant /server-clean: ${error.message || String(error)}`); - } - }, -}; diff --git a/index.js b/index.js index d079faa..98d0da8 100644 --- a/index.js +++ b/index.js @@ -120,7 +120,7 @@ client.on(Events.InteractionCreate, async interaction => { await command.execute(interaction, process.env.PALWORLD_API_TOKEN); } else if (interaction.commandName === 'trad') { await command.execute(interaction, translator); - } else if (interaction.commandName === 'start-server' || interaction.commandName === 'reboot-server' || interaction.commandName === 'server-clean') { + } else if (interaction.commandName === 'start-server' || interaction.commandName === 'reboot-server') { await command.execute(interaction, headers); } else { await command.execute(interaction); diff --git a/players-deleter b/players-deleter deleted file mode 100644 index fbf0490..0000000 Binary files a/players-deleter and /dev/null differ