couteau_suisse/commands/server/server-clean.js
2026-02-25 21:51:40 +01:00

309 lines
12 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('../../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/<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 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)}`);
}
},
};