// 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 { ActionRowBuilder, ActivityType, ButtonBuilder, ButtonStyle, EmbedBuilder, Events } from 'discord.js'; import { GameDig, games as gamedigGames } 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 🎮). Die Schlüssel sind gamedig-IDs — hier // standen mal ausgedachte wie "arkse" oder "terraria", die es dort gar nicht // gibt. Beim Ändern gegen `games` aus gamedig prüfen, nicht gegen das Gefühl. export const GAME_ICONS = { fivem: '🚗', http: '🌐', minecraft: '⛏️', mbe: '⛏️', teamfortress2: '🎩', counterstrike2: '🔫', csgo: '🔫', garrysmod: '🔧', rust: '🪓', valheim: '🛡️', palworld: '🐑', ase: '🦖', asa: '🦖', pixark: '🦖', dayz: '🧟', projectzomboid: '🧟', sdtd: '🧟', satisfactory: '🏭', terrariatshock: '🌳', arma3: '🪖', squad: '🪖', unturned: '🏝️', farmingsimulator19: '🚜', farmingsimulator22: '🚜', farmingsimulator25: '🚜', }; // fivem und http sind unsere eigenen Typen — gamedig kennt sie nicht. // // `panel` GAB ES HIER ALS DRITTEN, und er ist am 19.08.2026 entfallen: das // d4rk_gameserver-Panel baut und postet seine Status-Embeds seither selbst. // Ein Typ, der auf eine Anbindung zeigt, die es nicht mehr gibt, wäre ein // Eintrag, den man anlegen kann und der dann dauerhaft „offline" meldet. const EIGENE = { fivem: { name: 'FiveM', port: 30120 }, http: { name: 'HTTP-Check', port: null }, }; // Protokolle, die ohne Zugangs-Code gar nicht antworten. Der Code steht im // Webinterface des jeweiligen Servers — bei Farming Simulator zum Beispiel // unter http://host:8080/settings.html. Ohne ihn wirft gamedig, und der Server // stünde dauerhaft auf "offline", ohne dass man den Grund sähe. const TOKEN_PFLICHT = new Set(['farmingsimulator', 'terraria']); // Hier ist der Code optional: gamedig holt sich sonst selbst einen. const TOKEN_OPTIONAL = new Set(['satisfactory']); /** Klartext-Name eines Typs — "farmingsimulator25" → "Farming Simulator 25" */ export function spielName(type) { return EIGENE[type]?.name ?? gamedigGames[type]?.name ?? type; } /** 'pflicht' | 'optional' | null — steuert das Token-Feld im Panel */ export function tokenBedarf(type) { const protokoll = gamedigGames[type]?.options?.protocol; if (TOKEN_PFLICHT.has(protokoll)) return 'pflicht'; if (TOKEN_OPTIONAL.has(protokoll)) return 'optional'; return null; } /** Alle abfragbaren Spiele für die Auswahl im Panel, nach Namen sortiert */ export function spielListe() { const eigene = Object.entries(EIGENE).map(([id, e]) => ({ id, name: e.name, port: e.port, token: null, eigen: true, })); const aus_gamedig = Object.entries(gamedigGames) .map(([id, g]) => ({ id, name: g.name, port: g.options?.port ?? null, token: tokenBedarf(id), eigen: false, })) .sort((a, b) => a.name.localeCompare(b.name, 'de')); return [...eigene, ...aus_gamedig]; } // 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, version: null, mods: null, // Wie der Server sich selbst nennt, und ob er ein Passwort verlangt. // `null` heisst „sagt er nicht" — das ist etwas anderes als „nein". spielname: null, passwort: 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, spielname: data.hostname ?? 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, // Manche Spiele antworten nur mit Code — Farming Simulator etwa. // Leer weglassen, sonst schickt gamedig "token=" mit. token: server.token || 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, version: state.version || null, spielname: state.name || null, // 31 von 358 Protokollen melden das — valve (CS2, Rust, ARK, // Squad, Zomboid …) gehoert dazu, Minecraft und Farming Simulator // nicht. Wo nichts kommt, bleibt es null statt false. passwort: typeof state.password === 'boolean' ? state.password : null, // Nur Farming Simulator liefert die Modliste mit; anderswo bleibt // das Feld leer statt auf 0 zu stehen und eine Aussage zu machen. mods: Array.isArray(state.raw?.mods) ? state.raw.mods.length : null, playerNames: (state.players ?? []).map((p) => p?.name).filter(Boolean).slice(0, 15), }; } catch { return base; } } /** * Wer darf auf den Server? * * Nur 31 der 358 Protokolle melden überhaupt ein Passwort, und eine Whitelist * meldet gar keins — die steckt in einer Datei auf dem Server, nicht in der * Abfrage. Deshalb schlägt die Einstellung die Messung: wer „Whitelist" * einträgt, bekommt sie angezeigt, auch wenn der Server schweigt. * * `null` heisst „unbekannt" und ist etwas anderes als „offen". Ein Schloss, * das bei jedem schweigenden Server fehlt, wäre eine Aussage, die wir nicht * belegen können. * * @returns {{ zu: boolean|null, grund: string|null }} */ export function zugangStand(server, ergebnis = {}) { switch (server?.zugang) { case 'passwort': return { zu: true, grund: 'Passwort' }; case 'whitelist': return { zu: true, grund: 'Whitelist' }; case 'offen': return { zu: false, grund: null }; default: { const p = ergebnis.passwort; if (p === true) return { zu: true, grund: 'Passwort' }; if (p === false) return { zu: false, grund: null }; return { zu: null, grund: null }; } } } /** Nur http(s) darf in einen Link-Knopf — Discord weist alles andere ab */ export function istWebLink(url) { return /^https?:\/\/\S+$/i.test(String(url ?? '').trim()); } /** * Die klickbaren Links unter dem Embed. * * Vorher stand der Connect-Link als `[url](url)` im Embed-Feld. Discord macht * aus der nackten URL zwischen den Klammern selbst einen Link und kommt mit * der Masked-Link-Schreibweise dann nicht mehr durch — im Kanal standen die * eckigen Klammern wörtlich da. Ein Knopf lässt diese Frage gar nicht erst * aufkommen und ist obendrein als Knopf erkennbar. */ // Discord: fünf Knöpfe je Reihe. Mehr als zwei Reihen erschlägt das Embed, // also ist bei zehn Schluss — und zwar sichtbar hier, nicht als Fehler beim // Senden, den niemand sieht. const PRO_REIHE = 5; const MAX_REIHEN = 2; /** Alle Links eines Servers in einer Liste: die festen zuerst, dann die freien */ export function serverLinks(r) { return [ { label: '🎮 Verbinden', url: r.connect_url }, { label: '📦 Mods laden', url: r.mods_url }, ...(Array.isArray(r.links) ? r.links : []), ].filter((l) => l?.url && String(l.label ?? '').trim()); } export function serverKnoepfe(r) { const knoepfe = serverLinks(r) .filter((l) => istWebLink(l.url)) .slice(0, PRO_REIHE * MAX_REIHEN) .map((l) => new ButtonBuilder() .setStyle(ButtonStyle.Link) // Discord nimmt höchstens 80 Zeichen und weist längere ab .setLabel(String(l.label).trim().slice(0, 80)) .setURL(l.url.trim())); const reihen = []; for (let i = 0; i < knoepfe.length; i += PRO_REIHE) { reihen.push(new ActionRowBuilder().addComponents(knoepfe.slice(i, i + PRO_REIHE))); } return reihen; } /** Embed + Knöpfe — das, was der Monitor in den Kanal schreibt */ export function buildServerNachricht(r) { return { embeds: [buildServerEmbed(r)], components: serverKnoepfe(r) }; } /** GSM-Style-Embed für ein Query-Ergebnis */ // Discord setzt drei Inline-Felder in eine Reihe. Bleiben eins oder zwei // übrig, steht die letzte Reihe halb leer und alles wirkt schief. Ein Feld // aus unsichtbaren Zeichen füllt die Lücke — das ist der übliche Kniff, und // billiger als eine Layout-Entscheidung pro Kombination. const LEER = '​'; function dreierReihe(felder) { const drin = felder.filter(Boolean); if (drin.length === 0) return []; while (drin.length % 3 !== 0) drin.push({ name: LEER, value: LEER, inline: true }); return drin; } /** Auslastung als Balken — breiter als die alten zehn Zeichen */ function spielerBalken(players, max, breite = 16) { const anteil = Math.max(0, Math.min(1, players / max)); const voll = Math.round(anteil * breite); return `\`${'█'.repeat(voll)}${'░'.repeat(breite - voll)}\` **${players}**/${max} · ${Math.round(anteil * 100)} %`; } export function buildServerEmbed(r) { const icon = GAME_ICONS[r.type] ?? '🎮'; const zugang = zugangStand(r, r); const embed = new EmbedBuilder() .setColor(r.online ? GREEN : RED) .setTitle(`${icon} ${r.name}${zugang.zu ? ' 🔒' : ''}`) // 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')} • alle ${tuning('monitor_interval')} min` }) .setTimestamp(); if (r.image_url) embed.setThumbnail(r.image_url); // Kopf: Name im Spiel und der Zustand in einer Zeile. Als Beschreibung // statt als Feld — der Status ist die Überschrift und kein Datenpunkt, // und ein eigenes Feld dafür verbrauchte eine ganze Reihe. const kopf = []; if (r.spielname?.trim() && r.spielname.trim() !== r.name?.trim()) { // Kursiv statt Discords Kleintext-Auszeichnung: die rendert in Embeds // nicht verlässlich, und dann stünde das Steuerzeichen wörtlich da. kopf.push(`*${r.spielname.trim().slice(0, 200)}*`); } kopf.push([ r.online ? '🟢 **Online**' : '🔴 **Offline**', zugang.grund ? `🔒 ${zugang.grund}` : null, r.online && r.ping != null ? `${r.ping} ms` : null, ].filter(Boolean).join(' · ')); embed.setDescription(kopf.join('\n')); // Die Zahl, wegen der man hinschaut — über die volle Breite if (r.online && r.players != null && r.max) { embed.addFields({ name: 'Auslastung', value: spielerBalken(r.players, r.max) }); } // Genau eine volle Reihe, notfalls aufgefüllt embed.addFields(...dreierReihe([ r.map ? { name: 'Map', value: String(r.map).slice(0, 60), inline: true } : null, r.version ? { name: 'Version', value: String(r.version).slice(0, 60), inline: true } : null, r.mods != null ? { name: 'Mods', value: String(r.mods), inline: true } : null, ])); if (r.online && r.playerNames.length > 0) { embed.addFields({ name: `Wer drauf ist (${r.playerNames.length})`, value: r.playerNames.map((n) => `\`${n}\``).join(' ').slice(0, 1000), }); } // Zum Kopieren: die Adresse und alles, was Discord nicht klickbar macht // (fivem://, steam://, ts3server://). Ein Codeblock je Eintrag wäre eine // Wand — deshalb alle zusammen in einen. const zumKopieren = [ r.address ? `${r.address}` : null, ...serverLinks(r).filter((l) => !istWebLink(l.url)).map((l) => `${String(l.label).trim()}: ${l.url}`), ].filter(Boolean); if (zumKopieren.length > 0) { embed.addFields({ name: 'Zum Kopieren', value: `\`\`\`\n${zumKopieren.join('\n').slice(0, 1000)}\n\`\`\`` }); } 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; } // HIER STAND EINE SCHONFRIST AUS DEM PANEL: solange es meldete, der // Container starte noch oder sei absichtlich gestoppt, kam kein Alarm. // Mit der Panel-Anbindung ist sie entfallen (19.08.2026). // // WAS DAS KOSTET, und es gehört dazugesagt: ein Modpack, das startet, // antwortet minutenlang nicht — am ATM10 gemessen 306 Sekunden. Für // solche Server meldet der Monitor jetzt wieder einen Ausfall, wo vorher // „startet gerade" stand. Die Zwei-Fehlschläge-Schwelle unten federt das // teilweise ab, aber nicht ganz. // // Für Server, die im d4rk_gameserver-Panel stehen, ist das kein Verlust: // das Panel postet deren Status selbst und kennt den Unterschied. Für // Server, die nur hier stehen (FiveM, LS25), hat es ihn nie gegeben. 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; // NICHT `servers.map(queryServer)`, sondern der Pfeil drumherum: `map` // reicht (element, index, array) weiter, und ein zweiter Parameter bekäme // damit stillschweigend die Zahl 0. Der Aufruf hat heute nur einen — // trotzdem bleibt der Pfeil stehen, damit ein zweiter niemandem zur Falle // wird. const results = await Promise.all(servers.map((s) => queryServer(s))); // 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 = buildServerNachricht(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(() => {})); }