diff --git a/.env.example b/.env.example index f96e675..e8ae0d2 100644 --- a/.env.example +++ b/.env.example @@ -74,26 +74,26 @@ GITEA_API_TOKEN= # YTDLP_PATH=yt-dlp # --------------------------------------------------------------------------- -# VERBINDUNG ZUM SPIELSERVER (fuer /whitelist) +# ZUGANG ZUM ADMINPANEL (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. +# WOZU: der Bot fragt, 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. +# +# WARUM UEBER DAS PANEL UND NICHT DIREKT ZUM SPIELSERVER: sonst haetten ZWEI +# Dienste den Schluessel zu allem - Rechtematrix, Spielerliste, Protokoll, +# Schreibweg. Der Bot bekommt stattdessen einen eigenen, der genau eine Tuer +# oeffnet. # # OHNE DIESE ZWEI WERTE TUT /whitelist NICHTS und sagt es. Ein Bot, der bei # fehlender Konfiguration durchwinkt, waere schlimmer als einer, der schweigt. +# ACP_URL=https://acp.d4rkst3r.de # -# 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= +# Derselbe Wert wie BOT_TOKEN im Adminpanel. NICHT dessen D4RK_WEB_TOKEN - +# das ist der Schluessel zum Spielserver und geht den Bot nichts an. +# ACP_BOT_TOKEN= # # Die Rolle, die den Zutritt oeffnet. Sie MUSS dieselbe sein, die der # Spielserver prueft (Convar d4rk_discord:role) - stimmen sie nicht ueberein, diff --git a/docker-compose.yml b/docker-compose.yml index 9a31ed5..cf061d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,21 @@ services: # Lokale Zeitzone — wichtig für den Wochen-Rückblick (sonntags 20:00) und Datumsangaben TZ: ${TZ:-Europe/Berlin} DB_PATH: /app/data/ecobot.db + # --------------------------------------------------------------- + # Zugang zum Adminpanel (fuer /whitelist) + # --------------------------------------------------------------- + # + # Der Bot fragt das PANEL, nicht den Spielserver: sonst haetten zwei + # Dienste den Schluessel zu allem. Dieser hier oeffnet genau eine Tuer. + # + # Kein Vorgabewert - ein Bot, der bei fehlender Konfiguration heimlich + # irgendwohin verbindet, ist schlechter als einer, der "nicht + # eingerichtet" sagt. + ACP_URL: ${ACP_URL:-} + ACP_BOT_TOKEN: ${ACP_BOT_TOKEN:-} + # Die Discord-Rolle, die den Zutritt oeffnet. Muss dieselbe sein, die der + # Spielserver prueft (Convar d4rk_discord:role). + WHITELIST_ROLE_ID: ${WHITELIST_ROLE_ID:-} # HTTP-Endpoint für Gitea-Webhooks + Webinterface # NPM leitet bot.d4rkst3r.de → host.docker.internal:3080 ports: diff --git a/src/bot/commands/whitelist.js b/src/bot/commands/whitelist.js index ebdd544..9120e05 100644 --- a/src/bot/commands/whitelist.js +++ b/src/bot/commands/whitelist.js @@ -24,7 +24,7 @@ // gemessen hat, ist ein Schreibbefehl ins Ungewisse. import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { person, spielserverBereit, darfWhitelist } from '../../spielserver.js'; +import { person, panelBereit, darfWhitelist } from '../../panel.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 @@ -34,10 +34,10 @@ 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?', + 'Der Bot kennt das Adminpanel nicht — `ACP_URL` und `ACP_BOT_TOKEN` fehlen.', + panel_nicht_erreichbar: 'Das Adminpanel antwortet gerade nicht.', + zeitueberschreitung: 'Das Panel hat zu lange gebraucht.', + abgewiesen_oder_unbekannter_weg: 'Das Panel weist den Bot ab — stimmt der Schlüssel?', rechtesystem_stumm: 'Das Rechtesystem auf dem Spielserver antwortet nicht.', recht_nicht_lesbar: 'Die Rechteprüfung war nicht lesbar.', }; @@ -58,7 +58,7 @@ export async function execute(interaction) { // die gehören nicht in einen offenen Kanal. await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - if (!spielserverBereit()) { + if (!panelBereit()) { return interaction.editReply(GRUENDE.nicht_eingerichtet); } diff --git a/src/panel.js b/src/panel.js new file mode 100644 index 0000000..838e6c0 --- /dev/null +++ b/src/panel.js @@ -0,0 +1,102 @@ +// Die eine Stelle, an der der Bot mit dem Adminpanel redet. +// +// ============================================================================ +// WARUM ÜBER DAS PANEL UND NICHT DIREKT ZUM SPIELSERVER +// ============================================================================ +// Der erste Entwurf gab dem Bot das Geheimnis von `d4rk_web`. Dann hätten +// **zwei** Dienste den Schlüssel zu allem gehabt: Rechtematrix, Spielerliste, +// Protokoll, Schreibweg. Ein Bot, der nur wissen soll, ob jemand die Whitelist +// verwalten darf, braucht davon nichts. +// +// Betreiber am 04.09.2026: „können wir das nicht mit in die webseite einbauen, +// das er sich das über das acp holt?" — genau so ist es jetzt. Das Panel bleibt +// der einzige, der mit dem Spielserver spricht; der Bot bekommt einen eigenen +// Schlüssel, der genau eine Tür öffnet. +// +// ============================================================================ +// EIN FEHLSCHLAG IST EINE ANTWORT, KEINE AUSNAHME +// ============================================================================ +// Jede Funktion 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. + +const BASIS = process.env.ACP_URL || ''; +const TOKEN = process.env.ACP_BOT_TOKEN || ''; + +/** Ist der Zugang überhaupt eingerichtet? */ +export function panelBereit() { + return Boolean(BASIS && TOKEN); +} + +async function hole(weg) { + if (!panelBereit()) { + return { ok: false, grund: 'nicht_eingerichtet' }; + } + + const steuerung = new AbortController(); + const uhr = setTimeout(() => steuerung.abort(), 8000); + + try { + const antwort = await fetch(`${BASIS}/bot/${weg}`, { + headers: { 'X-Bot-Token': TOKEN }, + signal: steuerung.signal, + }); + + // 404 IST AUCH DIE ANTWORT AUF EINEN FALSCHEN SCHLÜSSEL — das Panel ist + // so gebaut, damit von außen nicht erkennbar ist, dass es einen + // Maschinenzugang gibt. Für uns heißt es deshalb nicht „Weg gibt es + // nicht", sondern „wir kommen nicht durch". + 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' + : 'panel_nicht_erreichbar', + }; + } finally { + clearTimeout(uhr); + } +} + +/** + * Alles, was der Spielserver über eine Discord-Kennung weiß. + * + * Zu jeder Quelle kommt ein Flag mit, 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 }; +} diff --git a/src/spielserver.js b/src/spielserver.js deleted file mode 100644 index 528a2d2..0000000 --- a/src/spielserver.js +++ /dev/null @@ -1,113 +0,0 @@ -// 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 }; -}