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 n’affiche 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] !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 l’API 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 > 40000) { 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 s’arrê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 };