Server-Tab (DiscordGSM-Stil), Team-Rechte und Bot-Profil-Beschreibung

- Game-Server: eigene Tabelle statt CSV-Setting (automatische Migration), eigener
  Setup-Tab mit Server-Builder (Name, Typ fivem/http, Query-URL, Anzeige-Adresse);
  pro Server ein Live-Embed (gruen/rot, Spieler-Balken, Map, Adresse als Code-Block),
  edit in place; Embed wird beim Server-Loeschen mit entfernt
- Team-System: web_admins mit Bereichs-Scopes (content/community/rollen/bewerbungen/
  server/settings/devlogs); Owner behaelt Brand/System/API-Keys/Team exklusiv;
  32 Routen-Guards auf Scopes umgestellt, sensible PUT-Felder fuer Team gestrippt;
  Team-Tab (Owner) + Tab-Filterung im Frontend, /api/me liefert Scopes
- Bot-Karte: Profil-Beschreibung (Ueber mich) via application.edit
- Fix: settings-Tabelle wird vor der Gameserver-Migration angelegt (frische DBs
  crashten sonst beim Start)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 01:23:11 +02:00
co-authored by Claude Fable 5
parent 19ec2802ac
commit 332e89dcbd
6 changed files with 533 additions and 115 deletions
+67 -57
View File
@@ -1,96 +1,106 @@
// Game-Server-Monitor: pollt FiveM-kompatible HTTP-Endpoints (/dynamic.json)
// und pflegt ein persistentes Status-Embed im Status-Kanal + Bot-Presence.
// Setting "gameservers": kommagetrennt "Name=http://ip:30120, Name2=…"
// Game-Server-Monitor (DiscordGSM-Stil): pro Server ein eigenes Status-Embed
// im Status-Kanal (edit in place, grün/rot), alle 2 Minuten aktualisiert.
// Server werden im Setup-Tab "Server" verwaltet (Typ: fivem | http).
import { ActivityType, EmbedBuilder, Events } from 'discord.js';
import { getSetting, setSetting } from '../db.js';
import { brandColor, brandFooter, botStatusText } from '../runtime-settings.js';
import { getSetting, listGameservers, setGameserverMessage } from '../db.js';
import { brandFooter, botStatusText } from '../runtime-settings.js';
const CHECK_INTERVAL_MS = 2 * 60 * 1000;
const TIMEOUT_MS = 8000;
const GREEN = 0x23a55a;
const RED = 0xf23f43;
function serverList() {
return (getSetting('gameservers') ?? '')
.split(',')
.map((entry) => {
const [name, ...rest] = entry.split('=');
const url = rest.join('=').trim().replace(/\/$/, '');
return name?.trim() && /^https?:\/\//.test(url) ? { name: name.trim(), url } : null;
})
.filter(Boolean);
}
/** Einen Server abfragen (FiveM-Format: clients, sv_maxclients, hostname) */
async function queryServer(server) {
/** Einen Server abfragen — je nach Typ */
export async function queryServer(server) {
const base = { ...server, online: false, players: null, max: null, map: null };
try {
const res = await fetch(`${server.url}/dynamic.json`, {
if (server.type === 'fivem') {
const res = await fetch(`${server.query_url.replace(/\/$/, '')}/dynamic.json`, {
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { 'User-Agent': 'd4rkbot-monitor/2.0' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return {
...base,
online: true,
players: Number(data.clients) || 0,
max: Number(data.sv_maxclients) || 0,
map: data.mapname ?? null,
};
}
// Typ 'http': generischer Healthcheck (2xx/3xx/4xx = erreichbar)
const res = await fetch(server.query_url, {
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { 'User-Agent': 'd4rkbot-monitor/1.0' },
redirect: 'follow',
headers: { 'User-Agent': 'd4rkbot-monitor/2.0' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return {
...server,
online: true,
players: Number(data.clients) || 0,
max: Number(data.sv_maxclients) || 0,
map: data.mapname ?? null,
};
return { ...base, online: res.status < 500 };
} catch {
return { ...server, online: false, players: 0, max: 0, map: null };
return base;
}
}
/** Status-Embed bauen */
function buildEmbed(results) {
const lines = results.map((r) =>
r.online
? `🟢 **${r.name}** — ${r.players}/${r.max} Spieler${r.map ? ` · ${r.map}` : ''}`
: `🔴 **${r.name}** — offline`
);
return new EmbedBuilder()
.setColor(brandColor())
.setTitle('🎮 Server-Status')
.setDescription(lines.join('\n'))
.setFooter({ text: `${brandFooter('STATUS')} • aktualisiert alle 2 min` })
/** Status-Embed für einen Server bauen (DiscordGSM-Look) */
export function buildServerEmbed(r) {
const embed = new EmbedBuilder()
.setColor(r.online ? GREEN : RED)
.setTitle(`${r.online ? '🟢' : '🔴'} ${r.name}`)
.addFields({ name: 'Status', value: r.online ? '**Online**' : '**Offline**', inline: true })
.setFooter({ text: `${brandFooter('STATUS')} • alle 2 min` })
.setTimestamp();
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}`, inline: true });
}
if (r.map) embed.addFields({ name: 'Map', value: r.map, inline: true });
if (r.address) embed.addFields({ name: 'Adresse', value: `\`\`\`\n${r.address}\n\`\`\`` });
return embed;
}
/** Ein Poll-Durchlauf — exportiert für Tests und den Timer */
export async function monitorTick(client) {
const servers = serverList();
const channelId = getSetting('status_channel_id');
const servers = listGameservers();
if (servers.length === 0) return;
const results = await Promise.all(servers.map(queryServer));
// Bot-Presence: Gesamtspielerzahl — nur wenn kein eigener Status (Brand-Tab) gesetzt ist
const online = results.filter((r) => r.online);
const totalPlayers = online.reduce((sum, r) => sum + r.players, 0);
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?.(
online.length > 0 ? `${totalPlayers} Spieler online` : 'Server offline',
results.some((r) => r.online) ? `${totalPlayers} Spieler online` : 'Server offline',
{ type: ActivityType.Watching }
);
} catch { /* Presence ist nice-to-have */ }
}
// Persistentes Embed im Status-Kanal pflegen (einmal posten, danach editieren)
// Pro Server ein Embed pflegen
const channelId = getSetting('status_channel_id');
if (!channelId) return;
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased()) return;
const embed = buildEmbed(results);
const messageId = getSetting('status_message_id');
if (messageId) {
const existing = await channel.messages.fetch(messageId).catch(() => null);
if (existing) {
await existing.edit({ embeds: [embed] });
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);
}
}
const message = await channel.send({ embeds: [embed] });
setSetting('status_message_id', message.id);
}
export function startServerMonitor(client) {