diff --git a/commands/server/server-clean.js b/commands/server/server-clean.js new file mode 100644 index 0000000..4aa4d78 --- /dev/null +++ b/commands/server/server-clean.js @@ -0,0 +1,308 @@ +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)}`); + } + }, +}; diff --git a/commands/server-stats/server-stats.js b/commands/server/server-stats.js similarity index 100% rename from commands/server-stats/server-stats.js rename to commands/server/server-stats.js diff --git a/index.js b/index.js index 28bd5ec..0468fb4 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') { + } else if (interaction.commandName === 'start-server' || interaction.commandName === 'reboot-server' || interaction.commandName === 'server-clean') { await command.execute(interaction, headers); } else { await command.execute(interaction); diff --git a/players-deleter b/players-deleter new file mode 100644 index 0000000..d05e013 Binary files /dev/null and b/players-deleter differ diff --git a/pterodactylFiles.js b/pterodactylFiles.js new file mode 100644 index 0000000..3145dcd --- /dev/null +++ b/pterodactylFiles.js @@ -0,0 +1,135 @@ +const axios = require('axios'); + +const ACCEPT_HEADER = 'Application/vnd.pterodactyl.v1+json'; + +const defaultBaseUrl = () => process.env.PTERODACTYL_API_URL || 'https://panel.louismazin.ovh'; +const defaultServerId = () => process.env.PTERODACTYL_SERVER_ID || 'ae4a628f'; + +const withAcceptHeader = (headers) => { + // Keep existing headers behavior, but ensure we can talk to Pterodactyl v1. + return { + ...(headers || {}), + Accept: headers?.Accept || ACCEPT_HEADER, + }; +}; + +const encodeQuery = (v) => encodeURIComponent(v); + +async function listDirectory({ headers, baseUrl, serverId, directory }) { + const url = `${baseUrl}/api/client/servers/${serverId}/files/list?directory=${encodeQuery(directory || '/')}`; + const res = await axios.get(url, { headers: withAcceptHeader(headers) }); + const data = res?.data?.data || res?.data?.attributes?.data || res?.data?.data; + + // Standard response: { object:'list', data:[{object:'file_object', attributes:{...}}] } + if (res?.data?.object === 'list' && Array.isArray(res?.data?.data)) { + return res.data.data.map((x) => x.attributes); + } + + // Fallback: try best-effort. + if (Array.isArray(data)) return data; + return []; +} + +async function getDownloadUrl({ headers, baseUrl, serverId, file }) { + const url = `${baseUrl}/api/client/servers/${serverId}/files/download?file=${encodeQuery(file)}`; + + // Most panels return JSON: { object:'signed_url', attributes:{ url } } + const res = await axios.get(url, { headers: withAcceptHeader(headers), validateStatus: () => true }); + + if (res.status >= 400) { + const msg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data); + throw new Error(`Pterodactyl download URL failed (${res.status}): ${msg}`); + } + + if (res.data && typeof res.data === 'object') { + const signed = + res.data?.attributes?.url || + res.data?.data?.attributes?.url || + res.data?.url; + if (signed) return String(signed); + } + + // Some installations may directly return the file, but in that case we don't have a URL. + return null; +} + +async function downloadFile({ headers, baseUrl, serverId, file }) { + const signedUrl = await getDownloadUrl({ headers, baseUrl, serverId, file }); + + if (signedUrl) { + const res = await axios.get(signedUrl, { + responseType: 'arraybuffer', + maxRedirects: 5, + validateStatus: () => true, + }); + if (res.status >= 400) { + throw new Error(`Signed download failed (${res.status}) for ${file}`); + } + return Buffer.from(res.data); + } + + // Fallback attempt: direct download from API. + const direct = `${baseUrl}/api/client/servers/${serverId}/files/download?file=${encodeQuery(file)}`; + const res = await axios.get(direct, { + headers: withAcceptHeader(headers), + responseType: 'arraybuffer', + maxRedirects: 5, + validateStatus: () => true, + }); + if (res.status >= 400) { + throw new Error(`Direct download failed (${res.status}) for ${file}`); + } + return Buffer.from(res.data); +} + +async function getUploadUrl({ headers, baseUrl, serverId, directory }) { + const url = `${baseUrl}/api/client/servers/${serverId}/files/upload?directory=${encodeQuery(directory || '/')}`; + const res = await axios.get(url, { headers: withAcceptHeader(headers) }); + const signed = res?.data?.attributes?.url || res?.data?.data?.attributes?.url; + if (!signed) { + throw new Error('Could not get signed upload URL from Pterodactyl'); + } + return String(signed); +} + +async function uploadSingleFile({ headers, baseUrl, serverId, directory, filename, content }) { + const signed = await getUploadUrl({ headers, baseUrl, serverId, directory }); + + // Use fetch + FormData (Node 18+) to match Pterodactyl upload behavior. + const form = new FormData(); + const blob = new Blob([content]); + form.append('files', blob, filename); + + const uploadUrl = `${signed}${signed.includes('?') ? '&' : '?'}directory=${encodeQuery(directory || '/')}`; + + const res = await fetch(uploadUrl, { + method: 'POST', + body: form, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Upload failed (${res.status}): ${text}`); + } +} + +async function deleteFiles({ headers, baseUrl, serverId, root, files }) { + const url = `${baseUrl}/api/client/servers/${serverId}/files/delete`; + await axios.post( + url, + { + root, + files, + }, + { headers: withAcceptHeader(headers) } + ); +} + +module.exports = { + defaultBaseUrl, + defaultServerId, + listDirectory, + downloadFile, + uploadSingleFile, + deleteFiles, +};