Server-Monitor: Der Footer behauptete fest "alle 2 min", obwohl das Intervall
seit den einstellbaren Werten aus monitor_interval kommt. Wer im Panel etwas
anderes setzt, bekam eine falsche Angabe. Liest jetzt den echten Wert.
Level-Aufstieg: War als einziges im Community-Bereich reiner Text, waehrend
Geburtstage, Verlosungen und Bewerbungen laengst Embeds sind. Jetzt ueber
brandEmbed mit Avatar, Gesamt-XP und Rest bis zum naechsten Level. Der Text
bleibt die Vorlage aus dem Panel — nur der Rahmen ist neu.
Dazu zeigt das Embed die Belohnungs-Rolle: entweder die gerade vergebene oder
die naechste, auf die man hinspielt. grantRewards() gibt dafuer zurueck, was
wirklich dazukam.
Ist die Vorlage im Panel leer, wirft setDescription('') — der Aufstieg waere
still ausgefallen. Faellt jetzt auf eine schlichte Zeile zurueck.
/rank: Avatar als Thumbnail, Podest-Zeichen fuer die ersten drei, Rest-XP und
naechste Belohnung als eigene Felder. Das Feld erscheint nur, wenn es
Belohnungs-Rollen gibt — sonst stuende dort ein leeres Feld.
Nebenbei zwei veraltete ephemeral: true auf flags: MessageFlags.Ephemeral
umgestellt, wie es der Rest der Befehle schon macht.
Geprueft: alle vier Embeds wirklich gebaut und ausgegeben, discord.js
validiert dabei. Das Intervall im Footer zog den auf 7 gestellten Wert.
Die negative XP-Differenz im ersten Testlauf war ein inkonsistenter
Testdatensatz — mit 4200 XP ist man Level 9, nicht 7; ueber 0..60000 XP
durchgespielt wird die Differenz nie negativ.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
216 lines
9.0 KiB
JavaScript
216 lines
9.0 KiB
JavaScript
// 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(() => {}));
|
|
}
|