7 Commits
Author SHA1 Message Date
D4rkst3randClaude Opus 5 00e4774a5f feat(whitelist): /whitelist add und remove
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Der lesende Weg ist am 04.09.2026 mit echten Daten durchgelaufen
("ja (2 von 2 Zutrittsrollen)"), also kommt jetzt die schreibende
Haelfte - in dieser Reihenfolge und nicht umgekehrt.

Welche Rolle vergeben wird
--------------------------
d4rk_discord:grantrole, eine EIGENE Convar in secrets.cfg. Nicht das
Adminpanel: eine Rollen-ID, die man an einer zweiten Stelle pflegt,
laeuft von der ersten weg - genau daran ist heute frueh schon das
Eingabefeld gescheitert. Die Rollenpolitik liegt vollstaendig in
secrets.cfg.

SIE MUSS EINE DER ZUTRITTSROLLEN SEIN. d4rk_lib prueft das und meldet
`vergabeGueltig`; ist sie es nicht, verweigert der Befehl. Eine Rolle
zu vergeben, die niemanden hereinlaesst, waere ein Knopf, der nichts
tut und dabei "erledigt" meldet - und das faellt erst auf, wenn jemand
vor der Tuer steht.

NICHT GESETZT IST KEIN FEHLER, sondern der Zustand im geschlossenen
Test. Der Befehl sagt das dann auch so.

Was geprueft wird, bevor etwas passiert
---------------------------------------
Rechtesystem erreichbar - Aufrufer hat d4rk.whitelist - Rollen lesbar -
Vergaberolle gesetzt - Vergaberolle oeffnet Zutritt - Person ist auf
dem Discord - Rolle existiert - Bot hat "Rollen verwalten" - Rolle
steht unter der hoechsten Rolle des Bots.

Die Hierarchiepruefung ist keine Kosmetik: ohne sie kommt eine nackte
HTTP-50013, und die liest sich wie "der Bot ist kaputt" statt wie "die
Rolle steht zu weit oben".

"Hat sie schon" und "hat sie gar nicht" sind Antworten, keine
Fehlschlaege.

Wo es landet
------------
Im Devlog-Kanal (neuer Helfer melden.js:inDevlog, wirft nie - ein
fehlgeschlagener Logeintrag darf den Vorgang nicht mitreissen, ueber
den er berichtet). NICHT im Serverprotokoll: der Weg dorthin ginge nur
ueber einen Schreibzugang fuer den Bot, und der wuerde die absichtlich
schmale Tuer aufmachen. Die Freischaltung steht ausserdem in Discords
Audit-Log, und beim Verbinden protokolliert d4rk_lib die Zulassung mit
Namen.

Gemessen
--------
Befehlsdefinition gebaut: whitelist status(person*) add(person*,grund)
remove(person*,grund), keine Ueberschreitung der Discord-Grenzen. Die
Grenzpruefung hat eine Kontrolle mit bekanntem Ausgang, die anschlaegt -
sonst waere "nichts ueber der Grenze" von "Pruefung ist blind" nicht zu
unterscheiden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:23:23 +02:00
D4rkst3randClaude Opus 5 90fefb2865 feat(zutritt): die Rollen werden gelesen, nicht abgeschrieben
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
GEMESSEN am 04.09.2026 an der Serverkonsole:

    "d4rk_discord:role" is "1439019849664696460,1544029550802108437"

ZWEI Rollen. Im Adminpanel stand seit heute frueh ein Eingabefeld, in
dem EINE davon stand. Wer nur die andere hat, kommt auf den Server -
und /whitelist status haette ihm "Whitelist-Rolle: nein" gesagt.

Das ist die Sorte Fehler, vor der der Kommentar an genau dieser Stelle
gewarnt hat, waehrend der Code daneben sie gebaut hat: zwei Orte fuer
dieselbe Wahrheit.

Was sich aendert
----------------
d4rk_lib exportiert `ZutrittRollen` - DIESELBE Parsung, die auch die
Zutrittspruefung benutzt, nicht eine zweite. Sie gibt Zutrittsrollen,
Sperrrollen, den ACE-Notausgang und ob der Discord-Token gesetzt ist
(nur ob, nie den Wert).

d4rk_web reicht das als /zutritt heraus. Nur lesend: setzen kann das
Panel es nicht, die Convar steht in secrets.cfg und wirkt erst nach
einem Serverstart. Ein Feld, das sich speichern laesst und nichts
aendert, ist schlimmer als keines.

Das Panel zeigt es an, statt es abzufragen. Das Eingabefeld ist weg;
ein bereits gespeicherter Wert bleibt unbeachtet liegen, statt beim
Start still geloescht zu werden.

Der Bot prueft gegen die MENGE, nicht gegen eine ID, und nennt die
Zahl ("ja (1 von 2)"). Die Sperrrolle wird ZUERST geprueft - sie
schlaegt jede Zutrittsrolle, und sie danach zu pruefen liesse ein "ja"
stehen fuer jemanden, der nicht hereinkommt.

KEIN RUECKFALL AUF EINE GESPEICHERTE ID mehr. Eine veraltete Liste
antwortet selbstbewusst falsch; "nicht pruefbar" ist die richtige
Antwort auf eine Frage, die man gerade nicht beantworten kann.

Gemessen nach dem Neustart:
  /zutritt -> rollen: 2, sperrrollen: 0, aceKnoten d4rk.whitelist,
              tokenGesetzt true  - deckt sich mit der Konsole

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:11:27 +02:00
D4rkst3randClaude Opus 5 8b4efb2cb9 feat(bot): Whitelist-Rolle kommt vom Panel, nicht aus der Umgebung
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Zwei Aenderungen, die zusammengehoeren.

1. darfWhitelist las ein Feld, das es nicht mehr gibt
------------------------------------------------------
/person im Spielserver nimmt jetzt das zu pruefende Recht als Parameter
und antwortet mit `darf` statt mit `darfWhitelist`. Der alte Zugriff
haette ab sofort still immer false ergeben - also: jeder im Team
ausgesperrt, ohne eine Fehlermeldung, die darauf hindeutet.

`person()` nimmt das Recht jetzt optional mit; ohne Recht bleibt der
Aufruf, was er war.

2. Die Rollen-ID stand an zwei Orten
------------------------------------
Sie muss zu der passen, die der Spielserver prueft. Stimmen sie nicht
ueberein, zeigt /whitelist status "hat die Rolle" fuer eine Rolle, die
den Zutritt gar nicht oeffnet - ein Fehler, der wie eine richtige
Antwort aussieht.

Deshalb wird sie nur noch an EINEM Ort eingetragen: auf der
Einstellungsseite des Adminpanels. Der Bot holt sie ueber /bot/config,
30 Sekunden gepuffert. Ein Fehlschlag wird NICHT gepuffert, sonst bliebe
eine Stoerung eine halbe Minute stehen, nachdem sie vorbei ist.

WHITELIST_ROLE_ID bleibt als Rueckfall, damit der Befehl auch dann noch
etwas anzeigen kann, wenn das Panel nicht antwortet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:58:47 +02:00
D4rkst3r c840f6fc7e merge: feat/bot-ueber-panel
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
2026-09-04 10:43:34 +02:00
D4rkst3randClaude Opus 5 b1ec1a2a29 feat(bot): der Bot fragt das Panel, nicht den Spielserver
Betreiber am 04.09.2026: "koennen wir das nicht mit in die webseite einbauen,
das er sich das ueber das acp holt?" - das ist der bessere Entwurf, und der
Grund ist das geringste Recht.

WAS DER ERSTE ANLAUF FALSCH GEMACHT HAETTE

Er haette dem Bot das Geheimnis von d4rk_web gegeben. Dann haetten ZWEI
Dienste den Schluessel zu allem gehabt: Rechtematrix, Spielerliste, Protokoll,
Schreibweg. Ein Bot, der nur wissen soll, ob jemand die Whitelist verwalten
darf, braucht davon nichts.

Jetzt bleibt das Panel der einzige, der mit dem Spielserver redet, und der Bot
bekommt einen eigenen Schluessel (BOT_TOKEN), der genau eine Tuer oeffnet:
/bot/person.

EIGENER PFAD, EIGENER WAECHTER

/bot/ statt /api/bot/, weil der Sitzungswaechter dort Discord-Anmeldung und
Rolle prueft - beides hat eine Maschine nicht. Der Botwaechter vergleicht den
Schluessel zeichenweise (dieselbe Ueberlegung wie in d4rk_web) und antwortet
bei fehlendem oder falschem Schluessel mit 404, nicht 401: wer von aussen
probiert, soll nicht erfahren, dass es hier einen Maschinenzugang gibt.

Unter 32 Zeichen gibt es den Weg gar nicht - eine halbe Konfiguration darf
keine Tuer aufmachen.

DURCHGEREICHT, NICHT AUSGEWERTET. Das Panel entscheidet an dieser Stelle
nichts; sonst waere es die zweite Rechteverwaltung, die ADR-0012 ausschliesst.

NEBENBEI ZWEI FALLEN VERMIEDEN

Dem Bot fehlt extra_hosts - host.docker.internal haette gar nicht aufgeloest,
und der Fehler haette wie "Spielserver antwortet nicht" ausgesehen. Ueber das
oeffentliche Panel braucht er es nicht.

Und das Panel nennt seine zwei Werte D4RK_WEB_URL/D4RK_WEB_TOKEN; mein
Botmodul hiess erst SPIELSERVER_*. Zwei Vokabeln fuer eine Sache bedeuten,
dass man beim Umzug an zwei Stellen sucht und eine vergisst. Die Datei heisst
jetzt panel.js - ein Modul namens spielserver.js, das mit dem Panel redet,
waere ein Name, der luegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:43:34 +02:00
D4rkst3r 6fab871225 merge: feat/bot-whitelist
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
2026-09-04 09:52:28 +02:00
D4rkst3randClaude Opus 5 958b3b64f2 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>
2026-09-04 09:52:28 +02:00
6 changed files with 558 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
# ---------------------------------------------------------------------------
# ZUGANG ZUM ADMINPANEL (fuer /whitelist)
# ---------------------------------------------------------------------------
#
# 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
#
# 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,
# zeigt /whitelist "hat die Rolle" fuer eine Rolle, die gar nichts oeffnet.
# WHITELIST_ROLE_ID=
+19
View File
@@ -37,6 +37,25 @@ 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. NUR NOCH RUECKFALL: seit
# dem 04.09.2026 holt der Bot sie vom Adminpanel (/bot/config), wo sie
# auf der Seite "Einstellungen" eingetragen wird. Was dort steht, gewinnt.
#
# Der Rueckfall bleibt, damit /whitelist auch dann noch etwas anzeigen
# kann, wenn das Panel gerade nicht antwortet.
WHITELIST_ROLE_ID: ${WHITELIST_ROLE_ID:-}
# HTTP-Endpoint für Gitea-Webhooks + Webinterface
# NPM leitet bot.d4rkst3r.de → host.docker.internal:3080
ports:
+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() {
+338
View File
@@ -0,0 +1,338 @@
// /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.
//
// ============================================================================
// DREI UNTERBEFEHLE, EINE RECHTEPRUEFUNG
// ============================================================================
// `status` liest, `add` und `remove` schreiben. Alle drei fragen zuerst den
// Spielserver, ob der Aufrufer `d4rk.whitelist` hat.
//
// Das Schreiben kam bewusst SPAETER als das Lesen: erst am 04.09.2026, nachdem
// der lesende Weg mit echten Daten durchgelaufen war („ja (2 von 2
// Zutrittsrollen)"). Ein Schreibbefehl auf einem Kanal, den niemand gemessen
// hat, ist ein Schreibbefehl ins Ungewisse.
//
// ============================================================================
// WAS DER BOT VERGIBT, ENTSCHEIDET DER SPIELSERVER
// ============================================================================
// Die Rolle steht in `d4rk_discord:grantrole` und MUSS eine der Rollen sein,
// die auch Zutritt oeffnen. Ist sie es nicht, verweigert dieser Befehl — eine
// Rolle zu vergeben, die niemanden hereinlaesst, waere ein Knopf, der nichts
// tut und dabei „erledigt" meldet.
//
// ============================================================================
// WO DAS LANDET
// ============================================================================
// Im Devlog-Kanal des Bots, nicht im Serverprotokoll. Der Weg dorthin ginge
// nur ueber einen Schreibzugang fuer den Bot, und der wuerde die absichtlich
// schmale Tuer aufmachen (siehe panel.js). Die Freischaltung selbst steht
// ausserdem in Discords eigenem Audit-Log, und wenn die Person verbindet,
// protokolliert d4rk_lib die Zulassung mit Namen.
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
import { person, panelBereit, darfWhitelist, konfig } from '../../panel.js';
import { inDevlog } from '../../melden.js';
/*
DIE ROLLEN WERDEN GELESEN, NICHT GEPFLEGT.
Sie sind kein Geheimnis, aber sie müssen zu denen passen, die der
Spielserver prüft (`d4rk_discord:role`) — sonst zeigt dieser Befehl „hat die
Rolle" für eine Rolle, die den Zutritt gar nicht öffnet.
Am 04.09.2026 gemessen: die Convar enthielt ZWEI Rollen, eingetragen war
eine. Deshalb kommt die Menge jetzt vom Spielserver.
ES SIND MEHRERE, UND EINE REICHT. Wer eine davon hat, kommt herein — außer
er hat eine Sperrrolle, die schlägt alles. Das ist dieselbe Regel wie in
d4rk_lib; sie hier zu vereinfachen hieße, sie ein zweites Mal zu schreiben.
KEIN RÜCKFALL AUF EINE GESPEICHERTE ID. Eine veraltete Liste würde
selbstbewusst falsch antworten; „nicht prüfbar" ist die richtige Antwort auf
eine Frage, die man gerade nicht beantworten kann.
*/
async function zutritt() {
const k = await konfig();
if (!k.ok || k.zutrittGelesen !== true || !k.zutritt) return null;
return k.zutritt;
}
const GRUENDE = {
nicht_eingerichtet:
'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.',
};
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)
)
)
.addSubcommand((s) =>
s.setName('add')
.setDescription('Jemanden freischalten')
.addUserOption((o) =>
o.setName('person').setDescription('Wen freischalten?').setRequired(true)
)
.addStringOption((o) =>
o.setName('grund').setDescription('Warum? Steht später im Log.').setRequired(false)
)
)
.addSubcommand((s) =>
s.setName('remove')
.setDescription('Freischaltung zurücknehmen')
.addUserOption((o) =>
o.setName('person').setDescription('Wem?').setRequired(true)
)
.addStringOption((o) =>
o.setName('grund').setDescription('Warum? Steht später im Log.').setRequired(false)
)
);
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 (!panelBereit()) {
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 was = interaction.options.getSubcommand();
if (was === 'add' || was === 'remove') {
return schreiben(interaction, ziel, was, erlaubnis.rolle);
}
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.
*/
const z = await zutritt();
let rolleText = 'nicht prüfbar — der Spielserver sagt gerade nicht, welche Rollen gelten';
if (z && z.rollen.length === 0) {
rolleText = 'keine Zutrittsrolle konfiguriert — die Whitelist ist offen';
} else if (z) {
const mitglied = await interaction.guild?.members
.fetch(ziel.id)
.catch(() => null);
if (!mitglied) {
rolleText = 'nicht auf diesem Discord';
} else {
const hat = z.rollen.filter((r) => mitglied.roles.cache.has(r));
const gesperrt = (z.sperrrollen ?? []).some((r) => mitglied.roles.cache.has(r));
// DIE SPERRE ZUERST: sie schlägt die Zutrittsrolle. Sie danach zu
// nennen ließe „ja" stehen für jemanden, der nicht hereinkommt.
rolleText = gesperrt
? '**gesperrt** — eine Sperrrolle schlägt jede Zutrittsrolle'
: hat.length > 0
? `**ja** (${hat.length} von ${z.rollen.length} Zutrittsrollen)`
: `**nein** (keine von ${z.rollen.length})`;
}
}
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'));
}
/**
* Freischalten oder zurücknehmen.
*
* SIEBEN PRÜFUNGEN, BEVOR ETWAS PASSIERT. Jede davon hat einen eigenen Satz —
* „geht nicht" ist keine Auskunft, mit der jemand weiterkommt.
*/
async function schreiben(interaction, ziel, was, aufruferRolle) {
const z = await zutritt();
if (!z) {
return interaction.editReply(
'Der Spielserver sagt gerade nicht, welche Rollen gelten — ich schalte nichts frei, was ich nicht prüfen kann.'
);
}
if (!z.vergaberolle) {
return interaction.editReply(
'Es ist keine Rolle zum Vergeben eingetragen (`d4rk_discord:grantrole` in `secrets.cfg`). Bis dahin kann ich niemanden freischalten.'
);
}
/*
DIE ROLLE MUSS AUCH ZUTRITT OEFFNEN.
Sonst vergibt der Befehl etwas Wirkungsloses und meldet Erfolg — die
unangenehmste Sorte Fehler, weil sie erst auffaellt, wenn jemand vor der
Tuer steht.
*/
if (!z.vergabeGueltig) {
return interaction.editReply(
`Die eingetragene Vergaberolle (\`${z.vergaberolle}\`) ist **keine** der Rollen, die Zutritt öffnen. ` +
'Ich vergebe sie nicht — sie würde niemanden hereinlassen.'
);
}
const mitglied = await interaction.guild?.members.fetch(ziel.id).catch(() => null);
if (!mitglied) {
return interaction.editReply(`${ziel.tag} ist nicht auf diesem Discord.`);
}
/*
KANN ICH DIE ROLLE UEBERHAUPT VERGEBEN?
Discord laesst einen Bot nur Rollen UNTERHALB seiner eigenen hoechsten
vergeben. Ohne diese Pruefung kommt eine nackte HTTP-50013, und die liest
sich wie „der Bot ist kaputt" statt wie „die Rolle steht zu weit oben".
*/
const rolle = interaction.guild.roles.cache.get(z.vergaberolle)
?? await interaction.guild.roles.fetch(z.vergaberolle).catch(() => null);
if (!rolle) {
return interaction.editReply(
`Die Rolle \`${z.vergaberolle}\` gibt es auf diesem Discord nicht. Steht in \`secrets.cfg\` die richtige ID?`
);
}
const ich = interaction.guild.members.me;
if (!ich?.permissions.has('ManageRoles')) {
return interaction.editReply('Mir fehlt das Recht **Rollen verwalten** auf diesem Discord.');
}
if (rolle.position >= ich.roles.highest.position) {
return interaction.editReply(
`**${rolle.name}** steht in der Rollenliste über meiner höchsten Rolle — Discord lässt mich sie deshalb nicht vergeben. ` +
'Schieb meine Rolle darüber.'
);
}
const hatte = mitglied.roles.cache.has(rolle.id);
// NICHTS ZU TUN IST EINE ANTWORT, KEIN FEHLSCHLAG.
if (was === 'add' && hatte) {
return interaction.editReply(`${ziel.tag} hat **${rolle.name}** bereits.`);
}
if (was === 'remove' && !hatte) {
return interaction.editReply(`${ziel.tag} hat **${rolle.name}** gar nicht.`);
}
const grund = interaction.options.getString('grund') || null;
const spur = `${interaction.user.tag} (${aufruferRolle})${grund ? `${grund}` : ''}`;
try {
if (was === 'add') await mitglied.roles.add(rolle, spur);
else await mitglied.roles.remove(rolle, spur);
} catch (fehler) {
return interaction.editReply(
`Discord hat es abgelehnt: ${fehler?.message ?? 'unbekannt'}`
);
}
/*
ERST HANDELN, DANN MELDEN - und die Meldung darf nicht mitreissen.
Ein fehlgeschlagener Logeintrag waere ein schlechter Grund, eine bereits
vergebene Rolle als Fehler darzustellen.
*/
await inDevlog(interaction.client, {
title: was === 'add' ? 'Whitelist: freigeschaltet' : 'Whitelist: zurückgenommen',
description:
`**${ziel.tag}** (\`${ziel.id}\`)\n` +
`Rolle: **${rolle.name}**\n` +
`Durch: **${interaction.user.tag}** (${aufruferRolle})` +
(grund ? `\nGrund: ${grund}` : ''),
color: was === 'add' ? 0x3fb950 : 0xd29922,
timestamp: new Date().toISOString(),
});
return interaction.editReply(
was === 'add'
? `${ziel.tag} hat jetzt **${rolle.name}** und kommt auf den Server.`
: `${ziel.tag} hat **${rolle.name}** nicht mehr.`
);
}
+32
View File
@@ -55,3 +55,35 @@ export async function dmAdminEinmalig(client, schluessel, zustand, embedBauen =
setSetting(merker, zustand);
return dmAdmin(client, embedBauen());
}
/**
* Ein Embed in den Devlog-Kanal.
*
* WARUM NICHT ALS DM: eine DM erreicht genau eine Person. Was das Team tut,
* gehört dorthin, wo das Team es sieht — sonst ist es protokolliert und
* trotzdem unsichtbar.
*
* WIRFT NIE. Ein fehlgeschlagener Logeintrag darf den Vorgang nicht
* mitreißen, über den er berichtet — dieselbe Regel wie bei `dmAdmin`. Der
* Rückgabewert sagt, ob es geklappt hat; wer es wissen muss, fragt ihn.
*
* @returns {Promise<boolean>}
*/
export async function inDevlog(client, embed) {
if (!config.devlogChannelId || !client) return false;
try {
const kanal = await client.channels.fetch(config.devlogChannelId).catch(() => null);
// Kein Textkanal ist kein Absturz: der Kanal kann gelöscht oder in
// eine Kategorie umgewandelt worden sein, und der Bot soll dann
// weiterarbeiten statt zu scheitern.
if (!kanal?.isTextBased?.()) return false;
await kanal.send({ embeds: [embed] });
return true;
} catch {
return false;
}
}
+140
View File
@@ -0,0 +1,140 @@
// 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, recht) {
// `recht` ist optional. Wird eines mitgegeben, prueft der Spielserver es
// gleich mit und legt `darf` in die Antwort - eine Frage, eine Runde.
const zusatz = recht ? `&recht=${encodeURIComponent(recht)}` : '';
const a = await hole(`person?discord=${encodeURIComponent(discordId)}${zusatz}`);
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, 'd4rk.whitelist');
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.darf === true, rolle: p.rolle };
}
/**
* Die Einstellungen, die im Panel eingetragen sind.
*
* WARUM DER BOT SIE NICHT SELBST FUEHRT: sonst stuende die Whitelist-Rolle an
* zwei Orten. Genau daran ist die erste Fassung gescheitert — am 04.09.2026 an
* der Serverkonsole gemessen:
*
* d4rk_discord:role = "1439019849664696460,1544029550802108437"
*
* ZWEI Rollen; eingetragen war eine. Wer nur die andere hatte, kam auf den
* Server und haette hier trotzdem „Whitelist-Rolle: nein" gelesen.
*
* Der Wert kommt deshalb aus der Convar des Spielservers, durchgereicht vom
* Panel — dieselbe Quelle, die auch ueber den Zutritt entscheidet.
*
* Kurz gepuffert, damit nicht jeder Befehl eine Runde ueber das Netz macht.
* Ein Fehlschlag wird NICHT gepuffert — sonst bliebe eine Stoerung eine Minute
* lang stehen, nachdem sie vorbei ist.
*/
let konfigStand = null;
let konfigZeit = 0;
export async function konfig() {
if (konfigStand && Date.now() - konfigZeit < 30000) return konfigStand;
const a = await hole('config');
if (!a.ok) return a;
konfigStand = a;
konfigZeit = Date.now();
return a;
}