couteau_suisse/consoleMonitor.js
2026-02-03 20:08:40 +01:00

660 lines
28 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 axios = require('axios');
const WebSocket = require('ws');
const { verifyLinkCode, updateUserLinkWithUsername, updateLastConnection } = require('./database.js');
const { handlePalworldChat } = require('./palworld-bridge.js');
let ws = null;
let reconnectTimeout = null;
let client = null;
let heartbeatInterval = null;
let heartbeatTimeout = null; // Timeout pour détecter si le serveur ne répond plus
let lastHeartbeatResponse = null; // Timestamp de la dernière réponse du serveur
let checkInterval = null;
let refreshCredentialsInterval = null; // Interval pour rafraîchir les credentials périodiquement
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
const CREDENTIALS_REFRESH_INTERVAL = 55 * 60 * 1000; // Rafraîchir toutes les 55 minutes (avant l'expiration)
// Suivi des joueurs connectés (fallback si la console naffiche pas les déconnexions)
const connectedPlayers = new Map(); // steamId -> { name, playerId, lastSeen }
// Suivi des logs déjà traités pour éviter les doublons
const processedLogs = new Set(); // Garde les 500 derniers logs traités
const MAX_PROCESSED_LOGS = 500;
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);
// Ajouter le rôle de joueur lié
try {
const guild = client.guilds.cache.get(process.env.GUILD_ID);
if (guild) {
const member = await guild.members.fetch(result.discordId);
const linkedRole = guild.roles.cache.get('1467491093649035475');
if (linkedRole && !member.roles.cache.has(linkedRole.id)) {
await member.roles.add(linkedRole);
console.log(`✅ Rôle ajouté à ${user.tag}`);
}
}
} catch (roleError) {
console.error('Erreur lors de l\'ajout du rôle:', roleError);
}
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(`🔄 [WEBSOCKET] Reconnexion WebSocket programmée dans ${Math.round(delay / 1000)}s... (Monitoring actif: ${isMonitoring})`);
reconnectTimeout = setTimeout(() => {
console.log('🔄 [WEBSOCKET] Tentative de reconnexion maintenant...');
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 (état:', ws.readyState, ')');
return;
}
if (isConnecting) {
console.log('⚠️ [WEBSOCKET] Connexion déjà en cours, abandon de cette tentative');
return;
}
isConnecting = true;
connectionTimestamp = Date.now();
console.log(`📅 [WEBSOCKET] Timestamp de connexion: ${new Date(connectionTimestamp).toISOString()}`);
try {
// Toujours rafraîchir les credentials pour éviter les tokens expirés après redémarrage serveur
console.log('🔑 [WEBSOCKET] Récupération de nouveaux credentials...');
const credentials = await getWebSocketCredentials(pterodactylToken, serverId);
console.log('✅ [WEBSOCKET] Credentials récupérés avec succès');
ws = new WebSocket(credentials.socket, {
origin: process.env.PTERODACTYL_API_URL
});
ws.on('open', () => {
console.log('✅ [WEBSOCKET] WebSocket Pterodactyl connecté avec succès');
isConnecting = false;
resetReconnectBackoff();
lastHeartbeatResponse = Date.now(); // Initialiser à maintenant
console.log('🔐 [WEBSOCKET] Envoi de l\'authentification...');
ws.send(JSON.stringify({
event: 'auth',
args: [credentials.token]
}));
});
// Pas besoin de handler 'pong' - on vérifie l'accès aux logs directement
ws.on('message', async (data) => {
try {
const message = JSON.parse(data.toString());
if (message.event === 'auth success') {
console.log('✅ [WEBSOCKET] Authentification WebSocket réussie');
console.log('📡 [WEBSOCKET] Demande de réception des logs console...');
ws.send(JSON.stringify({
event: 'send logs',
args: [null]
}));
// Configurer le heartbeat avec détection agressive
if (heartbeatInterval) clearInterval(heartbeatInterval);
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
lastHeartbeatResponse = Date.now();
heartbeatInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
try {
const timeSinceLastLog = Date.now() - lastHeartbeatResponse;
// Si on n'a pas reçu de log depuis plus de 40s, c'est que l'accès aux logs est perdu
if (timeSinceLastLog > 19000) {
console.error(`💔 [WEBSOCKET] Aucun log reçu depuis ${Math.round(timeSinceLastLog/1000)}s - accès aux logs perdu! Reconnexion forcée...`);
if (ws) {
ws.terminate(); // Fermeture immédiate sans attendre
}
return;
}
// Redemander l'accès aux logs pour s'assurer qu'on les reçoit toujours
ws.send(JSON.stringify({
event: 'send logs',
args: [null]
}));
console.log(`📡 [WEBSOCKET] Vérification accès aux logs (dernier reçu: ${Math.round(timeSinceLastLog/1000)}s)`);
} catch (error) {
console.error('❌ [WEBSOCKET] Erreur lors de la vérification des logs:', error.message);
if (ws) {
ws.terminate();
}
}
} else {
console.warn('⚠️ [WEBSOCKET] Vérification impossible: WebSocket non ouvert (état:', ws ? ws.readyState : 'null', ')');
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
}
}, 20000); // Vérifier toutes les 20s
console.log('📡 [WEBSOCKET] Vérification d\'accès aux logs configurée (20s, timeout 40s)');
}
// Détecter les réponses du serveur (console output, status, etc) pour mettre à jour le lastHeartbeatResponse
if (message.event === 'console output' || message.event === 'status' || message.event === 'stats') {
lastHeartbeatResponse = Date.now();
}
if (message.event === 'console output') {
const log = message.args[0];
// Protection 0: Vérifier si ce log a déjà été traité (évite les doublons lors des 'send logs' répétés)
if (processedLogs.has(log)) {
return;
}
const isChatMessage = log.includes('[CHAT]');
// 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
// S'applique à TOUS les messages (y compris chat) pour éviter de traiter l'historique au démarrage
if (monitoringStartTimestamp && logTimestamp < monitoringStartTimestamp) {
return;
}
}
// Marquer ce log comme traité
processedLogs.add(log);
// Limiter la taille du Set pour éviter une fuite mémoire
if (processedLogs.size > MAX_PROCESSED_LOGS) {
const firstItem = processedLogs.values().next().value;
processedLogs.delete(firstItem);
}
// Détecter si c'est un message de chat
if (isChatMessage) {
console.log(`💬 [CONSOLE] Message de chat détecté: ${log.substring(0, 100)}`);
// Transférer le message de chat vers Discord via le bridge
console.log(`📋 [CONSOLE] Traitement du log pour le bridge...`);
try {
await handlePalworldChat(log);
} catch (bridgeError) {
console.error('❌ [CONSOLE] Erreur lors du transfert vers le bridge:', bridgeError);
}
}
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('❌ [WEBSOCKET] Erreur WebSocket:', error.message);
console.error('❌ [WEBSOCKET] Code erreur:', error.code || 'N/A');
isConnecting = false;
});
ws.on('close', (code, reason) => {
const reasonStr = reason ? reason.toString() : 'Aucune raison fournie';
console.log(`⚠️ [WEBSOCKET] WebSocket Pterodactyl déconnecté (Code: ${code}, Raison: ${reasonStr})`);
console.log(`🔍 [WEBSOCKET] État actuel: isMonitoring=${isMonitoring}, isConnecting=${isConnecting}`);
ws = null;
isConnecting = false;
connectionTimestamp = null;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
console.log('💔 [WEBSOCKET] Heartbeat arrêté');
}
if (heartbeatTimeout) {
clearTimeout(heartbeatTimeout);
heartbeatTimeout = null;
}
// Planifier une reconnexion avec backoff (utile lors des redémarrages quotidiens du serveur)
if (isMonitoring) {
scheduleReconnect();
} else {
console.log('⚠️ [WEBSOCKET] Monitoring désactivé, pas de reconnexion');
}
});
} catch (error) {
console.error('❌ [WEBSOCKET] Erreur lors de la connexion WebSocket:', error.message);
console.error('❌ [WEBSOCKET] Stack:', error.stack);
isConnecting = false;
connectionTimestamp = null;
// Échec immédiat -> planifier reconnexion avec backoff
if (isMonitoring) {
scheduleReconnect();
} else {
console.log('⚠️ [WEBSOCKET] Monitoring désactivé, pas de reconnexion');
}
}
};
const stopWebSocketOnly = () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (heartbeatTimeout) {
clearTimeout(heartbeatTimeout);
heartbeatTimeout = null;
}
if (refreshCredentialsInterval) {
clearInterval(refreshCredentialsInterval);
refreshCredentialsInterval = null;
}
if (ws) {
ws.close();
ws = null;
}
isConnecting = false;
lastHeartbeatResponse = null;
};
const forceReconnectToRefreshCredentials = async () => {
if (!isMonitoring) return;
console.log('🔄 Rafraîchissement des credentials WebSocket (reconnexion préventive)...');
// Fermer la connexion actuelle proprement
if (ws) {
ws.close();
ws = null;
}
// Attendre un peu avant de reconnecter
setTimeout(async () => {
if (isMonitoring) {
await checkAndManageWebSocket();
}
}, 2000);
};
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
// Rafraîchir les credentials toutes les 55 minutes pour éviter l'expiration
if (refreshCredentialsInterval) clearInterval(refreshCredentialsInterval);
refreshCredentialsInterval = setInterval(forceReconnectToRefreshCredentials, CREDENTIALS_REFRESH_INTERVAL);
console.log(`⏱️ Rafraîchissement automatique des credentials programmé toutes les ${CREDENTIALS_REFRESH_INTERVAL / 60000} minutes`);
};
const stopConsoleMonitoring = () => {
stopWebSocketOnly();
if (checkInterval) {
clearInterval(checkInterval);
checkInterval = null;
}
if (refreshCredentialsInterval) {
clearInterval(refreshCredentialsInterval);
refreshCredentialsInterval = null;
}
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
isMonitoring = false;
monitoringStartTimestamp = null;
processedLogs.clear(); // Nettoyer le cache des logs traités
console.log('🔌 Surveillance de la console arrêtée');
};
// Fonction pour forcer la reconnexion du WebSocket (utilisée par ramMonitor)
const forceWebSocketReconnect = async () => {
console.log('🔄 [CONSOLE] Reconnexion forcée du WebSocket...');
await forceReconnectToRefreshCredentials();
};
// Fonction pour obtenir l'état du WebSocket (pour diagnostic)
const getWebSocketStatus = () => {
const wsStates = {
[WebSocket.CONNECTING]: 'CONNECTING',
[WebSocket.OPEN]: 'OPEN',
[WebSocket.CLOSING]: 'CLOSING',
[WebSocket.CLOSED]: 'CLOSED'
};
return {
isMonitoring,
isConnecting,
hasWebSocket: !!ws,
wsState: ws ? wsStates[ws.readyState] : 'N/A',
wsStateRaw: ws ? ws.readyState : null,
connectionTimestamp: connectionTimestamp ? new Date(connectionTimestamp).toISOString() : null,
monitoringStartTimestamp: monitoringStartTimestamp ? new Date(monitoringStartTimestamp).toISOString() : null,
lastHeartbeatResponse: lastHeartbeatResponse ? new Date(lastHeartbeatResponse).toISOString() : null,
timeSinceLastResponse: lastHeartbeatResponse ? Math.round((Date.now() - lastHeartbeatResponse) / 1000) : null,
reconnectDelayMs,
hasHeartbeat: !!heartbeatInterval,
hasHeartbeatTimeout: !!heartbeatTimeout,
hasCheckInterval: !!checkInterval,
hasRefreshInterval: !!refreshCredentialsInterval,
hasPendingReconnect: !!reconnectTimeout
};
};
module.exports = { startConsoleMonitoring, stopConsoleMonitoring, forceWebSocketReconnect, getWebSocketStatus };