465 lines
18 KiB
JavaScript
465 lines
18 KiB
JavaScript
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 n’affiche 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 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(`🔄 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 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('❌ 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 };
|