merge: feat/bot-whitelist
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s

This commit is contained in:
2026-09-04 09:52:28 +02:00
4 changed files with 285 additions and 1 deletions
+27
View File
@@ -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=
+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 };
}