Author SHA1 Message Date
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
D4rkst3r 7e7d04378a Pause fuer YouTube -- und der Bot bleibt draussen, wenn er rausgeht
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Zwei Befunde aus dem Betrieb.

1. Der Bot ging aus dem leeren Kanal und kam sofort wieder rein.

`stoppen()` hielt erst den Spieler an und loeschte dann aus `laufend`.
`player.stop()` meldet den Leerlauf aber SYNCHRON -- gemessen mit
@discordjs/voice 0.19: der Idle-Melder laeuft mitten in stop(), noch vor
der naechsten Zeile. Er fand den Eintrag also noch, hielt das Ende fuer
"Titel vorbei" und schaltete weiter: naechster Titel oder zurueck auf den
Heimsender. Von aussen genau das gemeldete Bild.

Jetzt wird erst geloescht, dann angehalten. Nachgestellt mit einem echten
AudioPlayer und beiden Reihenfolgen -- die Kontrolle ist dabei die alte
Reihenfolge: sie schaltet nachweislich weiter (1x), die neue nicht (0x).
Ohne diese Gegenprobe koennte der Test etwas ganz anderes messen.

2. Im YouTube-Modus fehlte die Pause.

`pausieren()` gibt es jetzt, ausdruecklich nur fuer YouTube. Ein Sender
laesst sich nicht sinnvoll anhalten: er laeuft beim Betreiber weiter,
waehrend hier die Leitung volllaeuft, und beim Fortsetzen kaeme eine
Konserve, die immer weiter hinterherhinkt. Fuer einen Sender heisst Pause
Stopp -- dafuer gibt es den Stopp-Knopf, und der Pause-Knopf graut aus.

Die verstrichene Zeit rechnet Pausen heraus, sonst liefe der
Fortschrittsbalken nach dem Fortsetzen um die Pausenlaenge voraus.
`verstrichen()` steht an EINER Stelle, die sich Discord-Tafel und Panel
teilen -- zwei Rechnungen waeren zwei Gelegenheiten, die Pause zu
vergessen.

Die Musik-Tafel bekommt eine zweite Knopfreihe: mit der Pause waeren es
sechs Knoepfe, Discord laesst fuenf je Reihe zu. Platz ist reichlich (vier
von fuenf Reihen), und die Trennung ist ohnehin sinnvoller -- oben, was
gerade klingt, unten, was mit der Liste passiert. Nachgezaehlt: 2 Reihen
ohne Liste, 4 mit.

Beim Quellenwechsel wird ausdruecklich fortgesetzt: der Spieler ueberlebt
den Wechsel, und stand er auf Pause, waere der neue Titel stumm gestartet
waehrend der Eintrag "laeuft" sagt.

Nebenbei: mein Feld-Abgleich hielt eine zweite Kopie der Feldliste von
`radioStand()` und driftete beim ersten neuen Feld prompt ab -- er meldete
`pausiert` als fehlend, obwohl die API es liefert. Er liest die Form jetzt
aus der Quelle, statt sie doppelt zu pflegen.
2026-08-28 21:46:24 +02:00
D4rkst3r 15babcd75d Zwei Fehler aus dem Betrieb: fehlender Import, geschlossenes stdin
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Beide aus dem Serverlog vom 28.08.2026, beide meine.

1. `getStation is not defined` bei jedem /api/radio/status, sobald etwas
lief. Die Funktion stand in api.js im Code und in keinem Import.

Weder `node --check` noch das Laden des Moduls sieht das: ESM prueft beim
Verlinken nur, ob die IMPORTIERTEN Namen in der Quelle existieren, nicht
ob die BENUTZTEN importiert sind. Mein API-Testlauf kam nie an die Zeile,
weil dort nie etwas lief -- genau die Luecke, die ich beim Ausliefern als
"nicht geprueft" benannt hatte.

Ein Pruefer dafuer liegt jetzt im Scratchpad und meldet fuer src/ null
Treffer. Er hat zwei Gegenproben: ein eingebauter Aufruf ohne Import muss
gefunden werden, ein dynamisch importierter darf NICHT gemeldet werden.
Seine ersten beiden Fassungen haben Fehlalarm geschlagen (14 bzw. 11
Treffer, alle dynamische Importe) -- ein Pruefer, der Richtiges als falsch
meldet, ist so wertlos wie einer, der nichts sieht.

2. `Cannot read properties of null (reading 'on')` in tonquelle, direkt
gefolgt von `ffmpeg: pipe:0: Invalid data found when processing input`.

`wandler` startete ffmpeg immer mit stdio ['ignore','pipe','pipe'] --
stdin also ZU. Fuer YouTube bekommt ffmpeg aber `-i pipe:0`. Damit las es
aus /dev/null, und `ff.stdin` war null, woran schon das Anhaengen der
Fehlerbehandlung zerbrach. Nachgestellt und bestaetigt: dieselbe
stdio-Form liefert stdin === null, mit 'pipe' eine Rohrleitung.

stdin ist jetzt offen, wenn die Eingabe `pipe:0` ist. Und `tonquelle`
prueft die Rohrenden, bevor es sie anfasst: startet ein Prozess nicht,
gibt es eine Meldung statt eines TypeErrors, der die ganze Interaktion
mitreisst.
2026-08-28 14:17:36 +02:00
D4rkst3r e6f8e3c3c5 Beim Sender kam die Warteschlange nie dran
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Im Betrieb gemeldet: Link eingereiht, Bot blieb beim Radio.

`ggfAnwerfen` stieg aus, sobald ueberhaupt etwas lief -- Sender wie Titel.
Der Kommentar daneben sagte "die Liste kommt dran, wenn sie dran ist", und
beim Sender war das schlicht falsch: ein Sender endet nie, es gibt also
kein Idle, das weiterschaltet. Der einzige Idle, den es gibt, ist ein
abgerissener Strom -- und der startet denselben Sender neu. Der Titel
wartete auf einen Moment, der nie kommt.

Damit war auch der Rueckweg unerreichbar: "Liste leer -> zurueck auf den
Sender" setzt voraus, dass man ueberhaupt erst vom Sender wegkommt.

Jetzt uebernimmt ein frisch eingereihter Titel sofort, wenn ein Sender
laeuft. Der Sender ist nicht verloren -- er steht als heimStation im
laufenden Eintrag und kommt zurueck, sobald die Liste leer ist. Ein
laufender YouTube-Titel wird weiterhin nicht unterbrochen.

Die Regel steckte als Bedingung mitten in der Funktion und war von aussen
weder zu lesen noch zu pruefen. Sie heisst jetzt `uebernimmtSofort()` und
laesst sich mit allen drei Eingaben durchspielen -- genau das ist gemacht:
nichts laeuft -> true, Sender laeuft -> true (war vorher false, das war der
Fehler), YouTube laeuft -> false.

Der Quellenwechsel geht jetzt ins Log. Von aussen sieht "YouTube
uebernimmt" genauso aus wie "nichts passiert", und der Unterschied ist
genau der, den es hier zu sehen gibt.

Die Musik-Tafel behauptete "kommt als Naechstes dran" -- korrigiert auf
"uebernimmt sofort".
2026-08-28 14:09:09 +02:00
9 changed files with 542 additions and 27 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:
+9
View File
@@ -2224,6 +2224,7 @@ export default function Settings({ me }) {
</span>
<div className="radio-now-text">
<strong>
{radio.laeuft.pausiert && '⏸️ '}
{radio.laeuft.art === 'youtube'
? radio.laeuft.titel
: radio.laeuft.sender}
@@ -2321,6 +2322,14 @@ export default function Settings({ me }) {
<option key={x.id} value={x.id}>{x.emoji || '📻'} {x.name}</option>
))}
</select>
{/* Pause gibt es nur bei YouTube: ein Dauerstrom laeuft
beim Sender weiter, waehrend hier die Leitung volllaeuft --
beim Fortsetzen kaeme eine Konserve, die hinterherhinkt. */}
<button className="btn btn-ghost"
disabled={radioLaedt || radio.laeuft?.art !== 'youtube'}
onClick={() => radioSteuern('pause')}>
{radio.laeuft?.pausiert ? '▶️ Weiter' : '⏸️ Pause'}
</button>
<button className="btn btn-ghost" disabled={radioLaedt || !radio.laeuft}
onClick={() => radioSteuern('skip')}>
Überspringen
+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() {
+159
View File
@@ -0,0 +1,159 @@
// /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, panelBereit, darfWhitelist, konfig } from '../../panel.js';
/*
DIE ROLLEN-ID STEHT IM PANEL, NICHT HIER.
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. Genau
deshalb wird sie an EINEM Ort eingetragen: auf der Einstellungsseite des
Adminpanels.
Die Umgebungsvariable bleibt als Rückfall, damit der Bot auch dann läuft,
wenn das Panel gerade nicht antwortet.
*/
async function whitelistRolle() {
const k = await konfig();
if (k.ok && k.whitelistRolle) return k.whitelistRolle;
return process.env.WHITELIST_ROLE_ID || '';
}
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)
)
);
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 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 rolle = await whitelistRolle();
let rolleText = 'nicht prüfbar — im Adminpanel ist keine Whitelist-Rolle eingetragen';
if (rolle) {
const mitglied = await interaction.guild?.members
.fetch(ziel.id)
.catch(() => null);
rolleText = !mitglied
? 'nicht auf diesem Discord'
: mitglied.roles.cache.has(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'));
}
+76 -17
View File
@@ -28,6 +28,7 @@ import {
} from '../db.js';
import {
laeuft, weiter, spielen, beiAenderung, tafelAktualisieren, tafelnMelden, radioQuelle,
pausieren, verstrichen,
} from './radio.js';
import {
aufloesen, ytdlpVorhanden, letzterFehler, TREFFER,
@@ -95,9 +96,10 @@ export function musikEmbed(client, guildId) {
if (jetzt?.quelle?.art === 'youtube') {
const t = jetzt.quelle.titel;
const verstrichen = Math.floor((Date.now() - jetzt.seit) / 1000);
const balken = fortschritt(verstrichen, t.dauer);
embed.setDescription(`**${kurz(t.titel, 200)}**\n`
// Pausen abgezogen — sonst behauptet der Balken, ein angehaltener
// Titel liefe weiter, und läuft beim Fortsetzen voraus.
const balken = fortschritt(verstrichen(jetzt), t.dauer);
embed.setDescription(`${jetzt.pausiertSeit ? '⏸️ ' : ''}**${kurz(t.titel, 200)}**\n`
+ `${t.kanal ? `_${kurz(t.kanal, 80)}_\n` : ''}`
+ (balken ? `${balken}\n` : '🔴 _Livestream_\n')
+ `in <#${jetzt.channelId}>`);
@@ -107,7 +109,7 @@ export function musikEmbed(client, guildId) {
// auch nicht als einer dargestellt.
const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null;
embed.setDescription(`📻 Gerade läuft das Radio${heim ? ` (**${heim.name}**)` : ''}.\n`
+ 'Was hier eingereiht wird, kommt als Nächstes dran.');
+ 'Was hier eingereiht wird, übernimmt sofort — danach geht es zurück auf den Sender.');
} else {
embed.setDescription(liste.length > 0
? 'Nichts läuft. **Überspringen** startet die Warteschlange — '
@@ -149,7 +151,7 @@ export function musikKomponenten(guildId) {
const reihen = [];
if (liste.length > 0) {
const optionen = (praefix) => liste.map((e, i) => {
const optionen = () => liste.map((e, i) => {
const o = new StringSelectMenuOptionBuilder()
.setLabel(kurz(`${i + 1}. ${e.titel}`, 100))
.setValue(String(e.id));
@@ -171,13 +173,30 @@ export function musikKomponenten(guildId) {
// an der Radio-Tafel: ein Knopf, der sich drücken lässt und nichts
// bewirkt, sieht aus wie ein kaputter Knopf.
const nichtsDa = liste.length === 0;
const ytLaeuft = jetzt?.quelle?.art === 'youtube';
const pausiert = Boolean(jetzt?.pausiertSeit);
// Zwei Knopfreihen statt einer: mit der Pause wären es sechs Knöpfe, und
// Discord lässt nur fünf je Reihe zu. Platz ist da — zwei Auswahlmenüs
// plus zwei Knopfreihen sind vier von fünf möglichen Reihen.
//
// Die Trennung ist ausserdem die sinnvollere: oben, was gerade klingt,
// unten, was mit der Liste passiert.
reihen.push(new ActionRowBuilder().addComponents(
// Pause gibt es nur bei YouTube. Warum, steht bei `pausieren()` in
// `radio.js` — kurz: einen Dauerstrom anzuhalten heisst, ihm
// hinterherzuhinken.
pausiert
? knopf('musik_pause', 'Weiter', '▶️', ButtonStyle.Success)
: knopf('musik_pause', 'Pause', '⏸️', ButtonStyle.Primary, !ytLaeuft),
knopf('musik_skip', 'Überspringen', '⏭️', ButtonStyle.Primary,
nichtsDa && !ytLaeuft),
knopf('musik_heim', 'Zum Radio', '📻', ButtonStyle.Secondary,
!ytLaeuft || !jetzt.heimStationId),
));
reihen.push(new ActionRowBuilder().addComponents(
knopf('musik_add', 'Einreihen…', '▶️', ButtonStyle.Success),
knopf('musik_skip', 'Überspringen', '⏭️', ButtonStyle.Primary,
nichtsDa && jetzt?.quelle?.art !== 'youtube'),
knopf('musik_leeren', 'Leeren', '🗑️', ButtonStyle.Danger, nichtsDa),
knopf('musik_heim', 'Zum Radio', '📻', ButtonStyle.Secondary,
!jetzt || jetzt.quelle?.art !== 'youtube' || !jetzt.heimStationId),
knopf('musik_refresh', 'Aktualisieren', '🔄'),
));
return reihen;
@@ -229,19 +248,55 @@ export function eintraegeAufnehmen(guildId, eintraege, wunschVon = null) {
}
/**
* Läuft nichts, soll das Einreihen sofort Musik machen.
* Nach dem Einreihen: soll das jetzt losgehen?
*
* Sonst legt jemand drei Titel in eine Liste, es passiert nichts, und der
* naheliegende Schluss wäre „kaputt". Läuft dagegen schon etwas — Sender oder
* Titel —, wird nicht dazwischengefunkt: die Liste kommt dran, wenn sie dran
* ist.
* Drei Fälle, und der mittlere ist der, den ich am 28.08.2026 falsch hatte:
*
* nichts läuft → die Liste fängt an. Sonst legt jemand drei Titel hinein,
* es passiert nichts, und der naheliegende Schluss wäre
* „kaputt".
* Sender läuft → **der Titel übernimmt sofort.** „Das Radio ist die
* Grundstellung, YouTube die Unterbrechung" — und eine
* Unterbrechung, die wartet, ist keine. Hier stand vorher
* „die Liste kommt dran, wenn sie dran ist", und das war
* beim Sender schlicht falsch: ein Sender endet nie, also
* gibt es kein `Idle`, das weiterschaltet. Der einzige
* `Idle`, den es gibt, ist ein abgerissener Strom — und der
* startet denselben Sender neu. Der Titel wartete also auf
* einen Moment, der nie kommt. Im Betrieb gemeldet: Link
* eingereiht, Bot blieb beim Radio.
* Der Sender ist nicht verloren: er steht als `heimStation`
* im laufenden Eintrag und kommt zurück, wenn die Liste
* leer ist.
* Titel läuft → nicht dazwischenfunken. Der Neue reiht sich ein, und
* *hier* stimmt „kommt dran, wenn er dran ist" auch.
*/
export async function ggfAnwerfen(client, guildId, kanalId) {
if (laeuft(guildId) || !kanalId) return false;
const q = await weiter(client, guildId, { erzwungen: true, kanalId });
const jetzt = laeuft(guildId);
if (!uebernimmtSofort(jetzt)) return false;
// Ohne eigene Angabe der Kanal, in dem der Bot schon steht.
const ziel = jetzt?.channelId ?? kanalId;
if (!ziel) return false;
const q = await weiter(client, guildId, { erzwungen: true, kanalId: ziel });
return Boolean(q);
}
/**
* Die Regel als eigener Satz — weil genau sie falsch war.
*
* Sie steckte als Bedingung mitten in `ggfAnwerfen` (`if (laeuft(guildId))
* return false`) und war von aussen weder zu lesen noch zu prüfen. Hier hat
* sie einen Namen und lässt sich mit allen drei Eingaben durchspielen.
*
* @param {object|null} jetzt Der laufende Eintrag, oder null
* @returns {boolean} true = der neue Titel fängt sofort an
*/
export function uebernimmtSofort(jetzt) {
// Nur ein laufender YouTube-Titel wird nicht unterbrochen. Ein Sender
// schon — er ist die Grundstellung, kein Vorrang.
return jetzt?.quelle?.art !== 'youtube';
}
// ── Suchtreffer zwischenlagern ────────────────────────────────────────────
//
// Ein Discord-Auswahlmenü gibt nur die `value` zurück, und da passt keine
@@ -300,7 +355,7 @@ async function zielKanal(interaction) {
}
const IDS = ['musik_add', 'musik_skip', 'musik_leeren', 'musik_heim', 'musik_refresh',
'musik_vor', 'musik_weg'];
'musik_pause', 'musik_vor', 'musik_weg'];
/** Knöpfe, Menüs und das Eingabefenster der Musik-Tafel */
export async function handleMusikInteraction(interaction) {
@@ -402,6 +457,10 @@ export async function handleMusikInteraction(interaction) {
// und das über den drei Sekunden liegt.
await interaction.deferUpdate();
await weiter(interaction.client, guildId, { erzwungen: true, kanalId: kanal.id });
} else if (customId === 'musik_pause') {
// Kein Aufschub nötig: Anhalten passiert im Speicher, ohne Netz.
pausieren(guildId);
await interaction.deferUpdate();
} else if (customId === 'musik_leeren') {
queueClear(guildId);
await interaction.deferUpdate();
+100 -5
View File
@@ -51,7 +51,7 @@ import {
import { moduleEnabled } from '../modules.js';
import {
listStations, getStation, radioState, radioStates, setRadioState, clearRadioState, setSetting,
queueNext, queueAdd, queueVor,
queueNext, queueAdd, queueVor, queueStand,
} from '../db.js';
import { senderGrundstock } from './radio-sender.js';
import { tonstrom, ytdlpPruefen, ytdlpVorhanden } from './youtube.js';
@@ -177,8 +177,14 @@ export async function titelHolen(url, { timeoutMs = 8000, bloecke = 3 } = {}) {
* ihn sonst endgültig beenden. Bei einer Rohrleitung wäre dieselbe Option
* sinnlos — es gibt nichts, wohin sich neu verbinden liesse.
*
* `netz` entscheidet ausserdem, ob stdin offen ist, und das ist keine
* Feinheit: bei `-i pipe:0` MUSS stdin eine Rohrleitung sein. Stand hier
* `ignore` (wie es bis zum 28.08.2026 der Fall war), zeigt `pipe:0` auf
* /dev/null — ffmpeg meldet dann „Invalid data found when processing input",
* und `ff.stdin` ist `null`, woran der Aufbau der Kette vorher zerbrach.
*
* @param {string} eingabe Adresse oder `pipe:0`
* @param {{netz: boolean}} opts
* @param {{netz: boolean}} opts netz=true: ffmpeg holt selbst; false: es liest aus stdin
*/
function wandler(eingabe, { netz }) {
const ff = spawn(FFMPEG, [
@@ -195,7 +201,8 @@ function wandler(eingabe, { netz }) {
'-f', 's16le', '-acodec', 'pcm_s16le',
'-ar', '48000', '-ac', '2',
'pipe:1',
], { stdio: ['ignore', 'pipe', 'pipe'] });
// Kein stdin bei einer Adresse, eine Rohrleitung bei `pipe:0`.
], { stdio: [netz ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
// Ohne Zuhörer auf stderr läuft die Pipe voll und ffmpeg bleibt stehen
ff.stderr.on('data', (d) => {
const text = d.toString().trim();
@@ -223,6 +230,17 @@ function tonquelle(quelle) {
}
const yt = tonstrom(quelle.titel.url);
const ff = wandler('pipe:0', { netz: false });
// Startet ein Prozess nicht, sind seine Rohrenden `null`. Ohne diese
// Prüfung wirft schon das Anhängen der Fehlerbehandlung einen
// TypeError — und der reisst die ganze Interaktion mit, statt dass
// jemand „kein Ton" zu sehen bekommt.
if (!yt.stdout || !ff.stdin) {
yt.kill('SIGKILL');
ff.kill('SIGKILL');
throw new Error('Tonkette liess sich nicht aufbauen — '
+ `yt-dlp: ${yt.stdout ? 'ok' : 'kein Ausgang'}, `
+ `ffmpeg: ${ff.stdin ? 'ok' : 'kein Eingang'}.`);
}
yt.stdout.on('error', () => {});
ff.stdin.on('error', () => {});
yt.stdout.pipe(ff.stdin);
@@ -485,17 +503,80 @@ export function lautstaerkeSetzen(guildId, wunsch) {
return wert;
}
/**
* Wie lange läuft der aktuelle Titel schon — Pausen abgezogen.
*
* An einer Stelle, weil drei Stellen es brauchen: die Musik-Tafel, das Panel
* und der Fortschrittsbalken. Wer das je einzeln rechnet, vergisst an einer
* davon die Pause, und dann behauptet ein Balken, ein angehaltener Titel
* liefe weiter.
*
* @returns {number} Sekunden
*/
export function verstrichen(eintrag) {
if (!eintrag) return 0;
const offen = eintrag.pausiertSeit ? Date.now() - eintrag.pausiertSeit : 0;
const netto = Date.now() - eintrag.seit - (eintrag.pauseGesamt ?? 0) - offen;
return Math.max(0, Math.floor(netto / 1000));
}
/** Läuft gerade etwas, das angehalten ist? */
export const istPausiert = (guildId) => Boolean(laufend.get(guildId)?.pausiertSeit);
/**
* Anhalten und weiterlaufen lassen.
*
* **Nur für YouTube, und das ist kein Vergessen.** Ein Sender ist ein
* Dauerstrom: hält man ihn an, läuft er beim Sender weiter, während sich
* hier die Leitung füllt. Beim Fortsetzen käme dann nicht „dort, wo es
* aufhörte", sondern eine Konserve, die immer weiter hinterherhinkt. Für
* einen Sender heisst Pause deshalb Stopp — und dafür gibt es den
* Stopp-Knopf.
*
* @param {string} guildId
* @param {boolean|null} wunsch true/false, oder null zum Umschalten
* @returns {boolean|null} der neue Zustand, oder null wenn nicht möglich
*/
export function pausieren(guildId, wunsch = null) {
const s = laufend.get(guildId);
if (!s || s.quelle?.art !== 'youtube') return null;
const soll = wunsch === null ? !s.pausiertSeit : Boolean(wunsch);
if (soll === Boolean(s.pausiertSeit)) return soll;
if (soll) {
// `true` schiebt Stille nach, statt die Verbindung stumm hängen zu
// lassen — sonst hält Discord den Sprecher für abgestürzt.
s.player.pause(true);
s.pausiertSeit = Date.now();
} else {
s.pauseGesamt = (s.pauseGesamt ?? 0) + (Date.now() - s.pausiertSeit);
s.pausiertSeit = null;
s.player.unpause();
}
return soll;
}
/** Alles abräumen — Verbindung, Spieler, ffmpeg */
export function stoppen(guildId) {
const s = laufend.get(guildId);
let abgeraeumt;
if (s) {
s.player.stop(true);
ketteAbraeumen(s);
// Reihenfolge ist hier alles: **erst aus der Karte, dann anhalten.**
//
// `player.stop()` meldet den Leerlauf SYNCHRON — gemessen am
// 28.08.2026 mit @discordjs/voice 0.19: der Idle-Melder läuft
// mitten in `stop()`, noch bevor die nächste Zeile dran ist. Stand
// `laufend.delete` danach, fand der Melder den Eintrag noch, hielt
// das Ende für „Titel vorbei" und schaltete weiter — auf den
// nächsten Titel oder zurück auf den Heimsender.
//
// Von aussen sah das so aus: der letzte verlässt den Kanal, der Bot
// geht raus und steht sofort wieder drin. Genau so gemeldet.
laufend.delete(guildId);
// Sonst zählt der nächste Start ab dem alten Stand weiter und bricht
// womöglich sofort wieder ab.
fehlschlaege.delete(guildId);
s.player.stop(true);
ketteAbraeumen(s);
// Die Statuszeile bleibt sonst stehen und behauptet stundenlang, es
// liefe ein Lied, das längst vorbei ist. Leerer Text räumt sie ab.
//
@@ -657,6 +738,11 @@ export async function spielen(kanal, quelle) {
// die Vorgabe ist leise, damit niemand erschrickt.
resource.volume?.setVolume(radioLautstaerke() / 100);
player.play(resource);
// Der Spieler wird über Quellenwechsel hinweg wiederverwendet. Stand er
// beim Umschalten auf Pause, bliebe der neue Titel stumm stehen — und der
// neue Eintrag sagt „läuft". Zwei Aussagen, eine Wirklichkeit: hier wird
// sie geradegezogen.
player.unpause();
verbindung.subscribe(player);
// Faengt der Ton wirklich an? Wenn ffmpeg fehlt oder der Sender nichts
@@ -715,6 +801,10 @@ export async function spielen(kanal, quelle) {
heimStationId: quelle.art === 'radio' ? quelle.station.id : (vorher?.heimStationId ?? null),
titel: quelle.art === 'youtube' ? quelle.titel.titel : null,
seit: Date.now(), tonLaeuft: null, client: kanal.client,
// Angehalten seit wann, und wie viel Pause insgesamt schon drin war.
// Ohne das zweite liefe der Fortschrittsbalken nach dem Fortsetzen
// um die Pausenlänge voraus.
pausiertSeit: null, pauseGesamt: 0,
};
laufend.set(guildId, eintrag);
// Neuer Anlauf für die Statuszeile: vielleicht wurde das Recht inzwischen
@@ -815,6 +905,11 @@ export async function weiter(client, guildId, { erzwungen = false, kanalId: star
}
throw error;
}
// Der Wechsel gehört ins Log: von aussen sieht „YouTube uebernimmt"
// genauso aus wie „nichts passiert", und der Unterschied ist genau
// der, den es hier zu sehen gibt.
console.log(`[radio] ${quelle.art === 'youtube' ? '▶️' : '📻'} ${quelleName(quelle)}`
+ `${naechster ? ` (noch ${queueStand(guildId).n} in der Liste)` : ' — Warteschlange leer'}`);
const tafel = radioState(guildId);
setRadioState({
guildId,
+133
View File
@@ -0,0 +1,133 @@
// 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 — hier in der Umgebung und dort auf der Seite. Zwei Orte fuer
* dieselbe Wahrheit heisst, dass eine Aenderung eine der beiden vergisst, und
* der Fehler sieht dann aus wie „die Rolle wird nicht erkannt".
*
* 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;
}
+17 -4
View File
@@ -28,7 +28,7 @@ import {
createService, updateService, listServices, deleteService,
savePage, getPage, listPages, listPublishedPages, deletePage,
lsState, lsModSizes, setLsModSize,
listStations, createStation, updateStation, deleteStation,
listStations, getStation, createStation, updateStation, deleteStation,
queueList, queueRemove, queueClear, queueReorder, queueVor, queueStand,
setRadioState, clearRadioState, radioState,
} from '../db.js';
@@ -40,7 +40,7 @@ import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../
import { istLsServer, modBasis } from '../bot/ls-farm.js';
import {
titelHolen, laeuft, spielen, stoppen, weiter, radioQuelle,
lautstaerkeSetzen, ffmpegVorhanden, tafelnMelden, titelMerken,
lautstaerkeSetzen, ffmpegVorhanden, tafelnMelden, titelMerken, pausieren, verstrichen,
} from '../bot/radio.js';
import { einreihen, eintraegeAufnehmen, ggfAnwerfen } from '../bot/radio-musik.js';
import {
@@ -469,8 +469,10 @@ export function registerApiRoutes(app, client) {
kanal: t?.kanal ?? null,
// Sekunden statt eines Zeitstempels: der Browser muesste sonst
// gegen die Uhr des Servers rechnen, und die beiden gehen selten
// gleich.
verstrichen: Math.floor((Date.now() - jetzt.seit) / 1000),
// gleich. `verstrichen()` zieht Pausen ab -- dieselbe Rechnung
// wie in der Discord-Tafel, nicht eine zweite daneben.
verstrichen: verstrichen(jetzt),
pausiert: Boolean(jetzt.pausiertSeit),
dauer: t ? t.dauer : null,
kanalId: jetzt.channelId,
kanalName: kanal?.name ?? null,
@@ -540,6 +542,17 @@ export function registerApiRoutes(app, client) {
// das am Ergebnis sehen.
await tafelnMelden(client, guildId);
return { ok: true, lautstaerke: wert, laeuft: radioStand(guild) };
} else if (aktion === 'pause') {
// Gibt null zurueck, wenn nichts oder ein Sender laeuft --
// dann ist Pause nicht vorgesehen, und das wird gesagt statt
// still nichts zu tun.
const neu = pausieren(guildId, b.wert === undefined ? null : Boolean(b.wert));
if (neu === null) {
return reply.code(400).send({
error: 'Pause gibt es nur bei YouTube -- ein Sender laesst sich nicht anhalten, '
+ 'nur stoppen.',
});
}
} else if (aktion === 'skip') {
const kanalId = zielId();
if (!kanalId) return reply.code(400).send({ error: 'Kein Sprachkanal.' });