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('../../database.js'); const { defaultBaseUrl, defaultServerId, listDirectory, downloadFile, uploadSingleFile, deleteFiles, } = require('../../pterodactylFiles.js'); const execFileAsync = promisify(execFile); const ROLE_RYGAINLAND = '1444684935632912394'; 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)); 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'); } const { stdout, stderr } = await execFileAsync(execPath, args, { timeout: 10 * 60 * 1000 }); 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 uidsToDelete = []; const missingPlayerId = []; for (const link of deduped) { 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).` : ''; await interaction.editReply(`✅ Aucun joueur supprimable trouvé.${extra}`); 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 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}\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)}`); } }, };