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 }; +}