// Game-Server-Monitor v3 (DiscordGSM-Stil): gamedig-Queries für 300+ Spiele, // pro Server ein Live-Embed (Spieler-Balken, Map, Player-Liste, Connect-Link), // Down/Up-Alerts mit 2-Fails-Schwelle. Verwaltet im Setup-Tab "Server". import { ActivityType, EmbedBuilder, Events } from 'discord.js'; import { GameDig } from 'gamedig'; import { getSetting, listGameservers, setGameserverMessage, recordPlayerSample, prunePlayerHistory } from '../db.js'; import { brandFooter, botStatusText } from '../runtime-settings.js'; import { brandEmbed } from '../embeds.js'; import { moduleEnabled } from '../modules.js'; import { tuning, tuningMs, everyTuned } from '../tuning.js'; const GREEN = 0x23a55a; const RED = 0xf23f43; // Emoji pro Spieltyp (Fallback 🎮) export const GAME_ICONS = { fivem: '🚗', http: '🌐', minecraft: '⛏️', minecraftbe: '⛏️', teamfortress2: '🎩', counterstrike2: '🔫', csgo: '🔫', garrysmod: '🔧', rust: '🪓', valheim: '🛡️', palworld: '🐑', ark: '🦖', arkse: '🦖', dayz: '🧟', projectzomboid: '🧟', sevendaystodie: '🧟', satisfactory: '🏭', terraria: '🌳', arma3: '🪖', squad: '🪖', unturned: '🏝️', }; // Alert-Zustand pro Server (in-memory) const alertState = new Map(); // id → { fails, down, since } // Letztes Query-Ergebnis pro Server — für die öffentliche /server-Seite export const lastResults = new Map(); // id → Query-Ergebnis + checkedAt /** Einen Server abfragen — fivem/http direkt, alles andere über gamedig */ export async function queryServer(server) { const base = { ...server, online: false, players: null, max: null, map: null, playerNames: [], ping: null, }; const started = Date.now(); try { if (server.type === 'fivem') { const res = await fetch(`${server.query_url.replace(/\/$/, '')}/dynamic.json`, { signal: AbortSignal.timeout(tuningMs.seconds('monitor_timeout')), headers: { 'User-Agent': 'd4rkbot-monitor/3.0' }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); return { ...base, online: true, ping: Date.now() - started, players: Number(data.clients) || 0, max: Number(data.sv_maxclients) || 0, map: data.mapname ?? null, }; } if (server.type === 'http') { const res = await fetch(server.query_url, { signal: AbortSignal.timeout(tuningMs.seconds('monitor_timeout')), redirect: 'follow', headers: { 'User-Agent': 'd4rkbot-monitor/3.0' }, }); return { ...base, online: res.status < 500, ping: Date.now() - started }; } // gamedig: Typ = Spiel-ID (minecraft, teamfortress2, rust, …) const state = await GameDig.query({ type: server.type, host: server.host, port: server.port || undefined, maxRetries: 1, socketTimeout: tuningMs.seconds('monitor_timeout'), }); return { ...base, online: true, ping: state.ping ?? Date.now() - started, players: state.players?.length ?? state.numplayers ?? 0, max: state.maxplayers ?? null, map: state.map || null, playerNames: (state.players ?? []).map((p) => p?.name).filter(Boolean).slice(0, 15), }; } catch { return base; } } /** GSM-Style-Embed für ein Query-Ergebnis */ export function buildServerEmbed(r) { const icon = GAME_ICONS[r.type] ?? '🎮'; const embed = new EmbedBuilder() .setColor(r.online ? GREEN : RED) .setTitle(`${icon} ${r.name}`) .addFields({ name: 'Status', value: r.online ? '🟢 **Online**' : '🔴 **Offline**', inline: true }) // Intervall aus den Werten lesen, nicht festschreiben — sonst behauptet // das Embed weiter „alle 2 min", nachdem im Panel etwas anderes steht. .setFooter({ text: `${brandFooter('STATUS')}${r.ping != null ? ` • ${r.ping}ms` : ''}` + ` • alle ${tuning('monitor_interval')} min`, }) .setTimestamp(); if (r.image_url) embed.setThumbnail(r.image_url); if (r.online && r.players != null && r.max) { const pct = Math.min(1, r.players / r.max); const bar = '█'.repeat(Math.round(pct * 10)).padEnd(10, '░'); embed.addFields({ name: 'Spieler', value: `\`${bar}\` ${r.players}/${r.max} (${Math.round(pct * 100)}%)`, inline: true, }); } if (r.map) embed.addFields({ name: 'Map', value: r.map, inline: true }); if (r.connect_url) { // steam:// & Co. sind in Embeds klickbar (Link-Buttons erlauben nur https) embed.addFields({ name: 'Connect', value: `[${r.connect_url}](${r.connect_url})`, inline: false }); } if (r.address) embed.addFields({ name: 'Adresse', value: `\`\`\`\n${r.address}\n\`\`\`` }); if (r.online && r.playerNames.length > 0) { embed.addFields({ name: `Spieler-Liste (${r.playerNames.length})`, value: r.playerNames.map((n) => `\`${n}\``).join(' ').slice(0, 1000), }); } return embed; } /** Down/Up-Alerts in den Alert-Kanal (2 Fails in Folge = down) */ async function handleAlerts(client, r) { const channelId = getSetting('server_alert_channel_id'); if (!channelId) return; const s = alertState.get(r.id) ?? { fails: 0, down: false, since: null }; if (r.online) { if (s.down) { const minutes = Math.max(1, Math.round((Date.now() - s.since) / 60000)); const channel = await client.channels.fetch(channelId).catch(() => null); await channel?.send({ embeds: [ brandEmbed(client, 'ALERT') .setColor(GREEN) .setTitle(`✅ ${r.name} ist wieder online!`) .setDescription(`Downtime: ~${minutes} min`), ], }).catch(() => {}); } alertState.set(r.id, { fails: 0, down: false, since: null }); return; } const fails = s.fails + 1; if (!s.down && fails >= tuning('monitor_fails')) { const channel = await client.channels.fetch(channelId).catch(() => null); await channel?.send({ embeds: [ brandEmbed(client, 'ALERT') .setColor(RED) .setTitle(`🚨 ${r.name} scheint down zu sein!`) .setDescription(r.address ? `\`${r.address}\`` : null), ], }).catch(() => {}); alertState.set(r.id, { fails, down: true, since: Date.now() }); } else { alertState.set(r.id, { ...s, fails }); } } /** Ein Poll-Durchlauf — exportiert für Tests und den Timer */ export async function monitorTick(client) { if (!moduleEnabled('server_monitor')) return; const servers = listGameservers(); if (servers.length === 0) return; const results = await Promise.all(servers.map(queryServer)); // Verlauf für die Web-Seite festhalten + Cache fürs API for (const r of results) { lastResults.set(r.id, { ...r, checkedAt: Date.now() }); try { recordPlayerSample(r.id, r.online, r.players); } catch { /* History ist nice-to-have */ } } try { prunePlayerHistory(); } catch { /* dito */ } // Bot-Presence: Gesamtspielerzahl — nur wenn kein eigener Status (Brand-Tab) gesetzt ist const withPlayers = results.filter((r) => r.online && r.players != null); const totalPlayers = withPlayers.reduce((sum, r) => sum + r.players, 0); if (!botStatusText()) { try { client.user?.setActivity?.( results.some((r) => r.online) ? `${totalPlayers} Spieler online` : 'Server offline', { type: ActivityType.Watching } ); } catch { /* Presence ist nice-to-have */ } } for (const r of results) await handleAlerts(client, r).catch(() => {}); // Pro Server ein Embed pflegen (edit in place) const channelId = getSetting('status_channel_id'); if (!channelId) return; const channel = await client.channels.fetch(channelId).catch(() => null); if (!channel?.isTextBased()) return; for (const r of results) { const payload = { embeds: [buildServerEmbed(r)] }; try { if (r.message_id) { const existing = await channel.messages.fetch(r.message_id).catch(() => null); if (existing) { await existing.edit(payload); continue; } } const message = await channel.send(payload); setGameserverMessage(r.id, message.id); } catch (error) { console.error(`[monitor] Embed für ${r.name} fehlgeschlagen:`, error.message); } } } export function startServerMonitor(client) { everyTuned('monitor_interval', 'minutes', () => monitorTick(client), 'monitor'); client.once(Events.ClientReady, () => monitorTick(client).catch(() => {})); }