server-clean
This commit is contained in:
parent
6cd2dc5258
commit
89fe02ef4b
308
commands/server/server-clean.js
Normal file
308
commands/server/server-clean.js
Normal file
@ -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/<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)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
2
index.js
2
index.js
@ -120,7 +120,7 @@ client.on(Events.InteractionCreate, async interaction => {
|
|||||||
await command.execute(interaction, process.env.PALWORLD_API_TOKEN);
|
await command.execute(interaction, process.env.PALWORLD_API_TOKEN);
|
||||||
} else if (interaction.commandName === 'trad') {
|
} else if (interaction.commandName === 'trad') {
|
||||||
await command.execute(interaction, translator);
|
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);
|
await command.execute(interaction, headers);
|
||||||
} else {
|
} else {
|
||||||
await command.execute(interaction);
|
await command.execute(interaction);
|
||||||
|
|||||||
BIN
players-deleter
Normal file
BIN
players-deleter
Normal file
Binary file not shown.
135
pterodactylFiles.js
Normal file
135
pterodactylFiles.js
Normal file
@ -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,
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user