couteau_suisse/commands/server/server-clean.js

373 lines
14 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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');
}
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 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/<uid>.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)}`);
}
},
};