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 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 09:52:28 +02:00
co-authored by Claude Opus 5
parent 7e7d04378a
commit 958b3b64f2
4 changed files with 285 additions and 1 deletions
+2 -1
View File
@@ -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() {
+143
View File
@@ -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'));
}
+113
View File
@@ -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 };
}