Files
d4rkbot/src/bot/server-monitor.js
T
D4rkst3randClaude Opus 5 19010e9fde
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Die Panel-Anbindung entfaellt -- das Panel postet jetzt selbst
Entscheidung des Betreibers am 19.08.2026: das d4rk_gameserver-Panel
baut und stellt seine Status-Embeds ab sofort selbst in den Discord.
Damit hat diese Anbindung keinen Zweck mehr.

WARUM SIE UEBERHAUPT WEG SOLL, und nicht bloss ungenutzt liegenbleibt:
sie war die zweite Meinung ueber denselben Zustand. `zustandText()` gab
`null` zurueck, wenn ein Server LIEF UND BEREIT war -- also im
Normalfall -- und der Monitor fiel dann auf seine gamedig-Abfrage
zurueck. Erreichte die den Server nicht, stand im Kanal "Offline",
waehrend dasselbe Embed RAM und CPU aus dem Panel anzeigte. Ein
Widerspruch, den man von keiner der beiden Seiten aufloesen konnte.

ENTFERNT:

    src/panel.js                      die Anbindung
    src/panel-abgleich.js             das Anlegen/Wegraeumen von Eintraegen
    tools/panel-pruefen.mjs           die zugehoerigen Proben
    tools/panel-abgleich-pruefen.mjs
    panelUrl/panelZeichen             in runtime-settings.js
    panel_url/panel_zeichen           als Einstellung und im Formular
    Servertyp `panel`                 er fragte nicht selbst, sondern las ab

WAS DER MONITOR DAMIT VERLIERT, und es gehoert dazugesagt: die
Schonfrist beim Hochfahren. Ein startendes Modpack antwortet minutenlang
nicht -- am ATM10 gemessen 306 Sekunden -- und meldet dadurch wieder
einen Ausfall, wo vorher "startet gerade" stand. Die
Zwei-Fehlschlaege-Schwelle federt das teilweise ab, nicht ganz.

Fuer Server im Panel ist das kein Verlust: dort postet das Panel und
kennt den Unterschied. Fuer FiveM und LS25 hat es ihn nie gegeben.

Geprueft: alle 281 relativen Importe loesen auf, die geaenderten Dateien
sind syntaktisch in Ordnung, das Frontend baut. Der Bot selbst ist NICHT
neu gestartet worden -- das ist eine Entscheidung des Betreibers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:01:21 +02:00

422 lines
18 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.
// 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(() => {}));
}