From 958b3b64f22a4c6c4d429b6834cd42e5a7743e53 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 4 Sep 2026 09:52:28 +0200 Subject: [PATCH] feat(bot): /whitelist status - der Bot fragt den Spielserver, wer was darf Betreiber am 04.09.2026: "wenn wir das in den d4rkbot mit einbauen koennen warum nicht". DIE WHITELIST *IST* EINE DISCORD-ROLLE d4rk_lib prueft bei jeder Verbindung live, ob die Person eine bestimmte Rolle auf diesem Discord hat. "Jemanden auf die Whitelist setzen" heisst also: ihm die Rolle geben. Das kann nur der Bot - der Spielserver hat keinen Zugriff auf Discord. Umgekehrt weiss nur der Spielserver, WER das darf. Deshalb fragt der Bot ihn, statt Discord-Berechtigungen zu pruefen: sonst gaebe es zwei Rechteverwaltungen, und die zweite erfuehre nie von einer Aenderung in der Matrix. Dieselbe Regel wie ADR-0012 fuer das Panel setzt. DIESER SCHRITT LIEST NUR `/whitelist status @person` beantwortet drei Fragen an einem Ort: hat sie die Rolle (fragt der Bot bei Discord), kennt der Spielserver ihr Konto, welche Teamrolle hat sie bei uns. Vergeben und Entziehen kommt als eigener Schritt - erst wenn dieser Weg nachweislich durchlaeuft. Ein Schreibbefehl auf einem Kanal, den niemand gemessen hat, ist ein Schreibbefehl ins Ungewisse. DREI AUSGAENGE BEI DER RECHTEFRAGE, NICHT ZWEI erlaubt, verboten, und "ich weiss es nicht". Der dritte ist der wichtige: ein Bot, der bei einer Stoerung "verboten" sagt, sperrt das Team aus; einer, der "erlaubt" sagt, oeffnet es fuer alle. Beides waere geraten. WAS DER SPIELSERVER NICHT WEISS, BEHAUPTET ER AUCH NICHT: ob die Discord-Rolle gesetzt ist, sieht er nur im Moment einer Verbindung. Der Bot fragt sie selbst - er hat den Zugang. DER ZWEITE HALTER DES GEHEIMNISSES Bisher kannte nur das Adminpanel den Token fuer d4rk_web. Der Bot ist jetzt der zweite. Beide sind eigene Container auf demselben Host und reden ueber das interne Netz - aber es bleibt eine Verdopplung, und sie steht deshalb im Kopf der Datei und in der .env.example, nicht in einer Fussnote. Ohne SPIELSERVER_URL und SPIELSERVER_TOKEN tut /whitelist NICHTS und sagt es. Ein Bot, der bei fehlender Konfiguration durchwinkt, waere schlimmer als einer, der schweigt. NOCH NICHT GEMESSEN: der Weg vom Bot zum Spielserver. Dafuer muessen die drei Umgebungsvariablen im Container gesetzt sein, und das ist eine Handlung des Betreibers. Die Serverseite (/person) ist dagegen gemessen: eine erfundene Kennung liefert rolle=keine, kontoGelesen=true, konto=nil. Co-Authored-By: Claude Opus 5 --- .env.example | 27 +++++++ src/bot/client.js | 3 +- src/bot/commands/whitelist.js | 143 ++++++++++++++++++++++++++++++++++ src/spielserver.js | 113 +++++++++++++++++++++++++++ 4 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 src/bot/commands/whitelist.js create mode 100644 src/spielserver.js diff --git a/.env.example b/.env.example index 9f6659c..f96e675 100644 --- a/.env.example +++ b/.env.example @@ -72,3 +72,30 @@ GITEA_API_TOKEN= # als "yt-dlp fehlt" erscheinen -- nicht als Stille im Sprachkanal. # FFMPEG_PATH=ffmpeg # YTDLP_PATH=yt-dlp + +# --------------------------------------------------------------------------- +# VERBINDUNG ZUM SPIELSERVER (fuer /whitelist) +# --------------------------------------------------------------------------- +# +# WOZU: der Bot fragt den Spielserver, WER die Whitelist verwalten darf. Die +# Antwort steht in der Rechtematrix - derselben, die Adminmenue und Panel +# benutzen. Wuerde der Bot stattdessen Discord-Berechtigungen pruefen, waere +# das eine zweite Rechteverwaltung, die von einer Aenderung in der Matrix nie +# erfaehrt. +# +# OHNE DIESE ZWEI WERTE TUT /whitelist NICHTS und sagt es. Ein Bot, der bei +# fehlender Konfiguration durchwinkt, waere schlimmer als einer, der schweigt. +# +# Die Adresse ist die INTERNE: der Endpunkt liegt auf dem Spielport, und dieser +# Weg soll nicht ueber das oeffentliche Netz laufen. +# SPIELSERVER_URL=http://host.docker.internal:30130/d4rk_web +# +# DASSELBE GEHEIMNIS wie im Adminpanel (d4rk_web:geheimnis in web.cfg). Der Bot +# ist damit der ZWEITE Halter - beide sind eigene Container auf demselben Host, +# aber es bleibt eine Verdopplung und ist hier ausdruecklich benannt. +# SPIELSERVER_TOKEN= +# +# Die Rolle, die den Zutritt oeffnet. Sie MUSS dieselbe sein, die der +# Spielserver prueft (Convar d4rk_discord:role) - stimmen sie nicht ueberein, +# zeigt /whitelist "hat die Rolle" fuer eine Rolle, die gar nichts oeffnet. +# WHITELIST_ROLE_ID= diff --git a/src/bot/client.js b/src/bot/client.js index ef80671..de99452 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -51,12 +51,13 @@ import * as raid from './commands/raid.js'; import * as umfrage from './commands/umfrage.js'; import * as radio from './commands/radio.js'; import * as musik from './commands/musik.js'; +import * as whitelist from './commands/whitelist.js'; // Alle Commands hier eintragen — jedes Modul exportiert { data, execute } const commandModules = [ ping, devlogBackfill, bug, playtesterSetup, galerieBackfill, wunsch, wunschSetup, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag, - raid, umfrage, radio, musik, + raid, umfrage, radio, musik, whitelist, ]; export async function startBot() { diff --git a/src/bot/commands/whitelist.js b/src/bot/commands/whitelist.js new file mode 100644 index 0000000..ebdd544 --- /dev/null +++ b/src/bot/commands/whitelist.js @@ -0,0 +1,143 @@ +// /whitelist — Zutritt zum Spielserver, von Discord aus. +// +// ============================================================================ +// DIE WHITELIST *IST* EINE DISCORD-ROLLE +// ============================================================================ +// d4rk_lib prüft bei jeder Verbindung live, ob die Person eine bestimmte Rolle +// auf diesem Discord hat. „Jemanden auf die Whitelist setzen" heißt deshalb +// nichts anderes als: ihm die Rolle geben. Das kann nur der Bot — der +// Spielserver hat keinen Zugriff auf Discord. +// +// Umgekehrt weiß nur der Spielserver, WER das darf. Also fragt der Bot ihn, +// statt Discord-Berechtigungen zu prüfen: sonst gäbe es zwei +// Rechteverwaltungen, und die zweite erführe nie von einer Änderung in der +// Matrix. +// +// ============================================================================ +// DIESER BEFEHL LIEST NUR +// ============================================================================ +// `status` beantwortet drei Fragen an einem Ort: hat die Person die Rolle, +// kennt der Spielserver ihr Konto, und welche Teamrolle hat sie bei uns. +// +// Das Vergeben und Entziehen kommt als eigener Schritt — erst wenn dieser +// Weg nachweislich durchläuft. Ein Schreibbefehl auf einem Kanal, den niemand +// gemessen hat, ist ein Schreibbefehl ins Ungewisse. +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; + +import { person, spielserverBereit, darfWhitelist } from '../../spielserver.js'; + +// Die Rollen-ID kommt aus derselben Umgebung wie der Rest der Botkonfiguration. +// Sie ist kein Geheimnis, aber sie muss zu der passen, die der Spielserver +// prüft (`d4rk_discord:role`) — stimmen sie nicht überein, zeigt dieser Befehl +// „hat die Rolle" für eine Rolle, die den Zutritt gar nicht öffnet. +const WHITELIST_ROLLE = process.env.WHITELIST_ROLE_ID || ''; + +const GRUENDE = { + nicht_eingerichtet: + 'Der Bot kennt den Spielserver nicht — `SPIELSERVER_URL` und `SPIELSERVER_TOKEN` fehlen.', + spielserver_nicht_erreichbar: 'Der Spielserver antwortet gerade nicht.', + zeitueberschreitung: 'Der Spielserver hat zu lange gebraucht.', + abgewiesen_oder_unbekannter_weg: 'Der Spielserver weist den Bot ab — stimmt das Geheimnis?', + rechtesystem_stumm: 'Das Rechtesystem auf dem Spielserver antwortet nicht.', + recht_nicht_lesbar: 'Die Rechteprüfung war nicht lesbar.', +}; + +export const data = new SlashCommandBuilder() + .setName('whitelist') + .setDescription('Zutritt zum Spielserver') + .addSubcommand((s) => + s.setName('status') + .setDescription('Was weiß der Server über diese Person?') + .addUserOption((o) => + o.setName('person').setDescription('Wen nachschlagen?').setRequired(true) + ) + ); + +export async function execute(interaction) { + // Nur für den Aufrufer sichtbar: hier stehen Kennungen und Zeitpunkte, und + // die gehören nicht in einen offenen Kanal. + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + if (!spielserverBereit()) { + return interaction.editReply(GRUENDE.nicht_eingerichtet); + } + + /* + ZUERST: DARF DER AUFRUFER DAS ÜBERHAUPT? + + Die Antwort kennt drei Ausgänge, und der dritte ist der wichtige: „ich + weiß es nicht". Bei einer Störung weder öffnen noch aussperren, sondern + sagen, dass die Auskunft fehlt. + */ + const erlaubnis = await darfWhitelist(interaction.user.id); + + if (!erlaubnis.bekannt) { + return interaction.editReply( + `Das lässt sich gerade nicht feststellen: ${GRUENDE[erlaubnis.grund] ?? erlaubnis.grund}` + ); + } + + if (!erlaubnis.darf) { + return interaction.editReply( + erlaubnis.grund === 'keine_rolle' + ? 'Du stehst nicht im Team des Servers.' + : `Deine Rolle **${erlaubnis.rolle}** darf die Whitelist nicht verwalten.` + ); + } + + const ziel = interaction.options.getUser('person', true); + const p = await person(ziel.id); + + if (!p.ok) { + return interaction.editReply(`Nachschlagen ging nicht: ${GRUENDE[p.grund] ?? p.grund}`); + } + + /* + DIE DISCORD-ROLLE FRAGT DER BOT SELBST. + + Der Spielserver weiß sie nicht — er sieht sie nur im Moment einer + Verbindung. Wer sie hier vom Server erwartet, bekommt eine Antwort, die + geraten wäre. + */ + let rolleText = 'nicht prüfbar — `WHITELIST_ROLE_ID` ist nicht gesetzt'; + + if (WHITELIST_ROLLE) { + const mitglied = await interaction.guild?.members + .fetch(ziel.id) + .catch(() => null); + + rolleText = !mitglied + ? 'nicht auf diesem Discord' + : mitglied.roles.cache.has(WHITELIST_ROLLE) + ? '**ja**' + : '**nein**'; + } + + const k = p.konto; + + const zeilen = [ + `**${ziel.tag}**`, + '', + `Whitelist-Rolle: ${rolleText}`, + // KEINE ROLLE IST EINE ANTWORT, kein Fehler: die meisten Spieler stehen + // nicht im Team. + `Teamrolle: ${p.rolle ? `**${p.rolle}**` : '—'}`, + ]; + + if (p.kontoGelesen === false) { + zeilen.push('Konto: *nicht lesbar* — das heißt **nicht**, dass es keins gibt.'); + } else if (!k) { + // War nie auf dem Server, ODER war da, bevor der Server sich Konten + // merkt (seit 04.09.2026). Beides sagt derselbe Satz, weil der Bot es + // nicht unterscheiden kann. + zeilen.push('Konto: **unbekannt** — noch nie verbunden, oder zuletzt vor dem 04.09.2026.'); + } else { + zeilen.push( + `Konto: **${k.name ?? '—'}** (${k.lizenzKurz ?? '—'})`, + `Charaktere: **${k.charaktere}** · zuletzt **${k.zuletzt}** · seit **${k.zuerst}**`, + k.online ? 'Gerade **online**.' : '' + ); + } + + return interaction.editReply(zeilen.filter(Boolean).join('\n')); +} diff --git a/src/spielserver.js b/src/spielserver.js new file mode 100644 index 0000000..528a2d2 --- /dev/null +++ b/src/spielserver.js @@ -0,0 +1,113 @@ +// Die eine Stelle, an der der Bot mit dem Spielserver redet. +// +// ============================================================================ +// WARUM DER BOT DEN SPIELSERVER FRAGT UND NICHT SELBST ENTSCHEIDET +// ============================================================================ +// Wer die Whitelist verwalten darf, steht in der Rechtematrix — dort, wo es +// auch für das Adminmenü und das Browserpanel steht. Ein Bot, der stattdessen +// Discord-Berechtigungen prüft, wäre eine zweite Rechteverwaltung: sie würde +// nichts von einer Änderung in der Matrix erfahren, und irgendwann darf +// jemand in Discord etwas, das ihm im Spiel längst genommen wurde. +// +// Das ist dieselbe Regel, die ADR-0012 für das Panel setzt: Discord-Kennung → +// vorhandenes Recht, keine zweite Verwaltung. +// +// ============================================================================ +// DAS GEHEIMNIS IST DER ZWEITE HALTER — UND DAS IST EINE ENTSCHEIDUNG +// ============================================================================ +// Bisher kannte nur das Adminpanel den Token für d4rk_web. Der Bot ist jetzt +// der zweite. Beide sind eigene Container auf demselben Host, beide reden über +// das interne Netz — aber es bleibt eine Verdopplung, und sie steht deshalb +// hier und nicht in einer Fußnote. +// +// Ohne Token macht dieser Baustein NICHTS und sagt es. Ein Bot, der bei +// fehlender Konfiguration einfach durchwinkt, wäre schlimmer als einer, der +// gar nicht antwortet. +// +// ============================================================================ +// EIN FEHLSCHLAG IST EINE ANTWORT, KEINE AUSNAHME +// ============================================================================ +// Jede Funktion hier gibt `{ ok: false, grund }` zurück statt zu werfen. Ein +// Discord-Befehl, der mit einem Stacktrace endet, zeigt dem Benutzer nichts — +// der Grund gehört in die Antwort, damit der Mensch davor weiß, was los ist. + +const BASIS = process.env.SPIELSERVER_URL || ''; +const TOKEN = process.env.SPIELSERVER_TOKEN || ''; + +/** Ist der Kanal überhaupt eingerichtet? Für Befehle, die das anzeigen wollen. */ +export function spielserverBereit() { + return Boolean(BASIS && TOKEN); +} + +async function hole(weg) { + if (!BASIS || !TOKEN) { + return { ok: false, grund: 'nicht_eingerichtet' }; + } + + const steuerung = new AbortController(); + const uhr = setTimeout(() => steuerung.abort(), 8000); + + try { + const antwort = await fetch(`${BASIS}/${weg}`, { + headers: { 'X-D4rk-Token': TOKEN }, + signal: steuerung.signal, + }); + + // 404 IST DIE ANTWORT AUF EIN FALSCHES GEHEIMNIS — so ist der Endpunkt + // gebaut, damit er von außen nicht als vorhanden erkennbar ist. Für uns + // heißt es also nicht „Weg gibt es nicht", sondern „wir kommen nicht + // durch", und genau das soll die Meldung sagen. + if (antwort.status === 404) { + return { ok: false, grund: 'abgewiesen_oder_unbekannter_weg' }; + } + + if (!antwort.ok) { + return { ok: false, grund: `http_${antwort.status}` }; + } + + return await antwort.json(); + } catch (fehler) { + return { + ok: false, + grund: fehler?.name === 'AbortError' + ? 'zeitueberschreitung' + : 'spielserver_nicht_erreichbar', + }; + } finally { + clearTimeout(uhr); + } +} + +/** + * Alles, was der Spielserver über eine Discord-Kennung weiß. + * + * Gibt `person` zurück mit: rolle, rang, darfWhitelist, konto — und zu jeder + * Quelle ein Flag, ob sie überhaupt geantwortet hat. Eine fehlende Quelle als + * „nichts gefunden" zu lesen wäre der Fehler, den diese Flags verhindern. + */ +export async function person(discordId) { + const a = await hole(`person?discord=${encodeURIComponent(discordId)}`); + + if (!a.ok) return a; + + return { ok: true, ...a.person }; +} + +/** + * Darf diese Discord-Kennung die Whitelist verwalten? + * + * DREI AUSGÄNGE, NICHT ZWEI: erlaubt, verboten, und „ich weiß es nicht". + * Der dritte ist der wichtige — ein Bot, der bei einer Störung „verboten" + * sagt, sperrt das Team aus; einer, der „erlaubt" sagt, öffnet es für alle. + * Beides wäre geraten, also wird es unterschieden. + */ +export async function darfWhitelist(discordId) { + const p = await person(discordId); + + if (!p.ok) return { bekannt: false, grund: p.grund }; + if (p.rolleGelesen === false) return { bekannt: false, grund: 'rechtesystem_stumm' }; + if (!p.rolle) return { bekannt: true, darf: false, grund: 'keine_rolle' }; + if (p.rechtGelesen === false) return { bekannt: false, grund: 'recht_nicht_lesbar' }; + + return { bekannt: true, darf: p.darfWhitelist === true, rolle: p.rolle }; +}