couteau_suisse/consoleMonitor.js
2026-01-13 00:12:40 +01:00

465 lines
18 KiB
JavaScript
Raw Permalink 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 axios = require('axios');
const WebSocket = require('ws');
const { verifyLinkCode, updateUserLinkWithUsername, updateLastConnection } = require('./database.js');
let ws = null;
let reconnectTimeout = null;
let client = null;
let heartbeatInterval = null;
let checkInterval = null;
let isMonitoring = false;
let isConnecting = false;
let connectionTimestamp = null;
let monitoringStartTimestamp = null; // Timestamp du démarrage du monitoring (ne change pas lors des reconnexions)
// Backoff de reconnexion
let reconnectDelayMs = 5000;
const RECONNECT_DELAY_MAX_MS = 5 * 60 * 1000; // 5 min
// Suivi des joueurs connectés (fallback si la console naffiche pas les déconnexions)
const connectedPlayers = new Map(); // steamId -> { name, playerId, lastSeen }
const parseLogMessage = (log) => {
// Format réel de log Palworld:
// [2025-12-09 13:28:23] [CHAT] <LouisMazin> !link X2NMAY
const linkRegex = /\[.*?\]\s*\[CHAT\]\s*<(.+?)>\s*!lier\s+([A-Z0-9]{6})/i;
const match = log.match(linkRegex);
if (match) {
const playerName = match[1].trim();
const code = match[2].toUpperCase();
console.log(`✅ Commande !lier détectée: ${playerName} avec le code ${code}`);
return {
type: 'link',
playerName: playerName,
code: code,
needsSteamId: true
};
}
// Détecter les déconnexions: [2025-12-09 18:55:19] [LOG] Nami left the server. (User id: gdk_2535420062888893)
const disconnectRegex = /\[.*?\]\s*\[LOG\]\s*(.+?)\s+left the server\.\s*\(User id:\s*(.+?)\)/i;
const disconnectMatch = log.match(disconnectRegex);
if (disconnectMatch) {
const playerName = disconnectMatch[1].trim();
const userId = disconnectMatch[2].trim();
console.log(`👋 Déconnexion détectée: ${playerName} (${userId})`);
return {
type: 'disconnect',
playerName: playerName,
userId: userId
};
}
return null;
};
const getSteamIdFromPlayerName = async (playerName) => {
try {
const response = await axios({
method: 'get',
maxBodyLength: Infinity,
url: 'http://play.louismazin.ovh:8212/v1/api/players',
headers: {
'Accept': 'application/json',
'Authorization': `Basic ${process.env.PALWORLD_API_TOKEN}`
}
});
const players = response.data.players || {};
console.log(`🔍 Recherche du Steam ID pour le joueur: ${playerName}`);
// Chercher le joueur par nom
for (const [steamId, player] of Object.entries(players)) {
if (player.name === playerName) {
return {
steamId: player.userId,
playerId: player.playerId,
name: player.name
};
}
}
return null;
} catch (error) {
console.error('Erreur lors de la récupération du Steam ID:', error.message);
return null;
}
};
const handleLinkCommand = async (playerName, playerData, code) => {
try {
console.log(`🔗 Tentative de liaison détectée: ${playerName} (${playerData.steamId}) avec le code ${code}`);
const result = await verifyLinkCode(code, playerData.steamId, playerData.name, playerData.playerId);
if (result.success) {
console.log(`✅ Liaison réussie pour ${playerName}`);
if (client) {
const user = await client.users.fetch(result.discordId).catch(() => null);
if (user) {
await updateUserLinkWithUsername(result.discordId, user.tag);
await user.send(
`✅ **Liaison réussie !**\n\n` +
`Votre compte Discord a été lié avec succès à votre compte Palworld:\n` +
`🎮 Nom Palworld: **${playerData.name}**\n` +
`🆔 Steam ID: \`${playerData.steamId}\`\n` +
`🎯 Player ID: \`${playerData.playerId}\``
).catch(() => {});
}
}
} else {
console.log(`❌ Échec de la liaison: ${result.message}`);
}
} catch (error) {
console.error('Erreur lors du traitement de la commande !link:', error);
}
};
const getWebSocketCredentials = async (pterodactylToken, serverId) => {
try {
const response = await axios({
method: 'get',
url: `${process.env.PTERODACTYL_API_URL}/api/client/servers/${serverId}/websocket`,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${pterodactylToken}`
}
});
return response.data.data;
} catch (error) {
console.error('Erreur lors de la récupération des credentials WebSocket:', error.message);
throw error;
}
};
// Helper: récupérer la liste actuelle des joueurs via lAPI Palworld
const fetchCurrentPlayers = async () => {
try {
const response = await axios({
method: 'get',
maxBodyLength: Infinity,
url: 'http://play.louismazin.ovh:8212/v1/api/players',
headers: {
'Accept': 'application/json',
'Authorization': `Basic ${process.env.PALWORLD_API_TOKEN}`
}
});
const players = response.data.players || {};
const list = [];
for (const [, player] of Object.entries(players)) {
list.push({
steamId: player.userId,
playerId: player.playerId,
name: player.name
});
}
return list;
} catch (error) {
// API inaccessible quand le serveur est down
return null;
}
};
// Mettre à jour le set des joueurs et détecter les départs silencieux
const pollPlayersAndDetectDisconnects = async (serverState = null) => {
const list = await fetchCurrentPlayers();
const now = Date.now();
if (list && list.length > 0) {
// Marquer présents
for (const p of list) {
connectedPlayers.set(p.steamId, { name: p.name, playerId: p.playerId, lastSeen: now });
}
// Détecter ceux qui ont disparu depuis le dernier poll
for (const [steamId, info] of connectedPlayers) {
const stillHere = list.find(x => x.steamId === steamId);
if (!stillHere) {
try {
const result = await updateLastConnection(steamId);
if (result.changes > 0) {
console.log(`✅ Départ silencieux détecté: ${info.name} (${steamId}) -> lastConnection mis à jour`);
}
} catch (e) {
console.error(`❌ Erreur update lastConnection pour ${steamId}:`, e.message);
}
connectedPlayers.delete(steamId);
}
}
} else {
// Pas de liste (serveur inaccessible) ou vide:
// Si le serveur est arrêté/stopping/offline, considérer tous comme déconnectés
if (serverState && ['offline', 'stopping', 'stopped'].includes(serverState)) {
for (const [steamId, info] of connectedPlayers) {
try {
const result = await updateLastConnection(steamId);
if (result.changes > 0) {
console.log(`✅ Serveur ${serverState}: déconnexion implicite de ${info.name} (${steamId})`);
}
} catch (e) {
console.error(`❌ Erreur update lastConnection pour ${steamId}:`, e.message);
}
}
connectedPlayers.clear();
}
}
};
const checkAndManageWebSocket = async () => {
const { cleanExpiredCodes } = require('./database.js');
try {
await cleanExpiredCodes();
// Fallback polling toutes les 20s
await pollPlayersAndDetectDisconnects();
// Le WebSocket doit toujours être connecté maintenant (pour les déconnexions)
if (!ws && !isConnecting) {
console.log('🔌 Connexion au WebSocket pour surveillance...');
const serverId = process.env.PTERODACTYL_SERVER_ID;
const pterodactylToken = process.env.PTERODACTYL_API_TOKEN;
await connectWebSocket(pterodactylToken, serverId);
}
} catch (error) {
console.error('Erreur lors de la vérification du WebSocket:', error);
}
};
const resetReconnectBackoff = () => {
reconnectDelayMs = 5000;
};
const scheduleReconnect = () => {
if (!isMonitoring) return;
if (reconnectTimeout) clearTimeout(reconnectTimeout);
const delay = reconnectDelayMs;
reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_DELAY_MAX_MS);
console.log(`🔄 Reconnexion WebSocket dans ${Math.round(delay / 1000)}s...`);
reconnectTimeout = setTimeout(() => {
checkAndManageWebSocket();
}, delay);
};
const connectWebSocket = async (pterodactylToken, serverId) => {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
console.log('⚠️ WebSocket déjà connecté ou en cours de connexion');
return;
}
if (isConnecting) {
console.log('⚠️ Connexion WebSocket déjà en cours');
return;
}
isConnecting = true;
connectionTimestamp = Date.now();
console.log(`📅 Timestamp de connexion: ${new Date(connectionTimestamp).toISOString()}`);
try {
// Toujours rafraîchir les credentials pour éviter les tokens expirés après redémarrage serveur
const credentials = await getWebSocketCredentials(pterodactylToken, serverId);
ws = new WebSocket(credentials.socket, {
origin: process.env.PTERODACTYL_API_URL
});
ws.on('open', () => {
console.log('✅ WebSocket Pterodactyl connecté');
isConnecting = false;
resetReconnectBackoff();
ws.send(JSON.stringify({
event: 'auth',
args: [credentials.token]
}));
});
ws.on('message', async (data) => {
try {
const message = JSON.parse(data.toString());
if (message.event === 'auth success') {
console.log('✅ Authentification WebSocket réussie');
ws.send(JSON.stringify({
event: 'send logs',
args: [null]
}));
if (heartbeatInterval) clearInterval(heartbeatInterval);
heartbeatInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ event: 'send heartbeat', args: [] }));
}
}, 30000);
}
if (message.event === 'console output') {
const log = message.args[0];
// Extraire le timestamp du log
const timestampMatch = log.match(/\[(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\]/);
if (timestampMatch) {
const logTimestamp = new Date(timestampMatch[1]).getTime();
const now = Date.now();
// Protection 1: Ignorer les messages très anciens (>15 min) pour éviter l'historique complet
if (now - logTimestamp > 15 * 60 * 1000) {
return;
}
// Protection 2: Ignorer les messages antérieurs au démarrage du monitoring
// (évite de traiter les vieux codes lors du redémarrage du bot)
if (monitoringStartTimestamp && logTimestamp < monitoringStartTimestamp) {
return;
}
}
const linkData = parseLogMessage(log);
if (linkData) {
if (linkData.type === 'link') {
// Traiter de manière asynchrone sans bloquer le handler WebSocket
(async () => {
try {
const playerData = await getSteamIdFromPlayerName(linkData.playerName);
if (playerData) {
await handleLinkCommand(linkData.playerName, playerData, linkData.code);
} else {
console.log(`❌ Impossible de trouver le Steam ID pour ${linkData.playerName}`);
}
} catch (error) {
console.error(`❌ Erreur lors du traitement de !lier pour ${linkData.playerName}:`, error);
}
})();
} else if (linkData.type === 'disconnect') {
// Traiter de manière asynchrone sans bloquer le handler WebSocket
(async () => {
try {
const result = await updateLastConnection(linkData.userId);
if (result.changes > 0) {
console.log(`✅ Dernière connexion mise à jour pour ${linkData.playerName} (${linkData.userId})`);
} else {
console.log(` Aucun compte lié trouvé pour ${linkData.playerName} (${linkData.userId})`);
}
} catch (error) {
console.error('Erreur lors de la mise à jour de la dernière connexion:', error);
}
})();
}
}
}
if (message.event === 'status') {
const state = message.args[0];
console.log('📊 Statut du serveur:', state);
// Si le serveur sarrête/offline, forcer la mise à jour des joueurs connectés (déconnexions implicites)
if (['offline', 'stopping', 'stopped'].includes(state)) {
await pollPlayersAndDetectDisconnects(state);
}
}
} catch (error) {
console.error('Erreur parsing message WebSocket:', error);
}
});
ws.on('error', (error) => {
console.error('❌ Erreur WebSocket:', error.message);
isConnecting = false;
});
ws.on('close', (code, reason) => {
console.log(`⚠️ WebSocket Pterodactyl déconnecté (Code: ${code})`);
ws = null;
isConnecting = false;
connectionTimestamp = null;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
// Planifier une reconnexion avec backoff (utile lors des redémarrages quotidiens du serveur)
scheduleReconnect();
});
} catch (error) {
console.error('Erreur lors de la connexion WebSocket:', error);
isConnecting = false;
connectionTimestamp = null;
// Échec immédiat -> planifier reconnexion avec backoff
scheduleReconnect();
}
};
const stopWebSocketOnly = () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (ws) {
ws.close();
ws = null;
}
isConnecting = false;
};
const startConsoleMonitoring = (discordClient, pterodactylToken) => {
if (isMonitoring) {
console.log('⚠️ Surveillance déjà active');
return;
}
client = discordClient;
isMonitoring = true;
monitoringStartTimestamp = Date.now();
console.log(`📅 Démarrage du monitoring: ${new Date(monitoringStartTimestamp).toISOString()}`);
resetReconnectBackoff();
const serverId = process.env.PTERODACTYL_SERVER_ID;
if (!serverId) {
console.error('❌ PTERODACTYL_SERVER_ID non défini dans .env');
return;
}
console.log('🔍 Surveillance de la console Pterodactyl démarrée (mode intelligent)');
// Vérifier immédiatement
checkAndManageWebSocket();
// Vérifier toutes les 20 secondes (nettoyage codes et reconnect si besoin)
if (checkInterval) clearInterval(checkInterval);
checkInterval = setInterval(checkAndManageWebSocket, 20000); // 20s pour détecter vite les départs silencieux
};
const stopConsoleMonitoring = () => {
stopWebSocketOnly();
if (checkInterval) {
clearInterval(checkInterval);
checkInterval = null;
}
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
isMonitoring = false;
monitoringStartTimestamp = null;
console.log('🔌 Surveillance de la console arrêtée');
};
module.exports = { startConsoleMonitoring, stopConsoleMonitoring };