Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90fefb2865 | ||
|
|
8b4efb2cb9 | ||
|
|
c840f6fc7e | ||
|
|
b1ec1a2a29 | ||
|
|
6fab871225 | ||
|
|
958b3b64f2 | ||
|
|
7e7d04378a | ||
|
|
15babcd75d | ||
|
|
e6f8e3c3c5 | ||
|
|
c46581a548 |
@@ -72,3 +72,30 @@ GITEA_API_TOKEN=
|
|||||||
# als "yt-dlp fehlt" erscheinen -- nicht als Stille im Sprachkanal.
|
# als "yt-dlp fehlt" erscheinen -- nicht als Stille im Sprachkanal.
|
||||||
# FFMPEG_PATH=ffmpeg
|
# FFMPEG_PATH=ffmpeg
|
||||||
# YTDLP_PATH=yt-dlp
|
# 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=
|
||||||
|
|||||||
@@ -37,6 +37,25 @@ services:
|
|||||||
# Lokale Zeitzone — wichtig für den Wochen-Rückblick (sonntags 20:00) und Datumsangaben
|
# Lokale Zeitzone — wichtig für den Wochen-Rückblick (sonntags 20:00) und Datumsangaben
|
||||||
TZ: ${TZ:-Europe/Berlin}
|
TZ: ${TZ:-Europe/Berlin}
|
||||||
DB_PATH: /app/data/ecobot.db
|
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
|
# HTTP-Endpoint für Gitea-Webhooks + Webinterface
|
||||||
# NPM leitet bot.d4rkst3r.de → host.docker.internal:3080
|
# NPM leitet bot.d4rkst3r.de → host.docker.internal:3080
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -2224,6 +2224,7 @@ export default function Settings({ me }) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="radio-now-text">
|
<div className="radio-now-text">
|
||||||
<strong>
|
<strong>
|
||||||
|
{radio.laeuft.pausiert && '⏸️ '}
|
||||||
{radio.laeuft.art === 'youtube'
|
{radio.laeuft.art === 'youtube'
|
||||||
? radio.laeuft.titel
|
? radio.laeuft.titel
|
||||||
: radio.laeuft.sender}
|
: radio.laeuft.sender}
|
||||||
@@ -2321,6 +2322,14 @@ export default function Settings({ me }) {
|
|||||||
<option key={x.id} value={x.id}>{x.emoji || '📻'} {x.name}</option>
|
<option key={x.id} value={x.id}>{x.emoji || '📻'} {x.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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}
|
<button className="btn btn-ghost" disabled={radioLaedt || !radio.laeuft}
|
||||||
onClick={() => radioSteuern('skip')}>
|
onClick={() => radioSteuern('skip')}>
|
||||||
⏭️ Überspringen
|
⏭️ Überspringen
|
||||||
|
|||||||
+2
-1
@@ -51,12 +51,13 @@ import * as raid from './commands/raid.js';
|
|||||||
import * as umfrage from './commands/umfrage.js';
|
import * as umfrage from './commands/umfrage.js';
|
||||||
import * as radio from './commands/radio.js';
|
import * as radio from './commands/radio.js';
|
||||||
import * as musik from './commands/musik.js';
|
import * as musik from './commands/musik.js';
|
||||||
|
import * as whitelist from './commands/whitelist.js';
|
||||||
|
|
||||||
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
||||||
const commandModules = [
|
const commandModules = [
|
||||||
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
|
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
|
||||||
wunsch, wunschSetup, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
|
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() {
|
export async function startBot() {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// /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 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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
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 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'));
|
||||||
|
}
|
||||||
+76
-17
@@ -28,6 +28,7 @@ import {
|
|||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
import {
|
import {
|
||||||
laeuft, weiter, spielen, beiAenderung, tafelAktualisieren, tafelnMelden, radioQuelle,
|
laeuft, weiter, spielen, beiAenderung, tafelAktualisieren, tafelnMelden, radioQuelle,
|
||||||
|
pausieren, verstrichen,
|
||||||
} from './radio.js';
|
} from './radio.js';
|
||||||
import {
|
import {
|
||||||
aufloesen, ytdlpVorhanden, letzterFehler, TREFFER,
|
aufloesen, ytdlpVorhanden, letzterFehler, TREFFER,
|
||||||
@@ -95,9 +96,10 @@ export function musikEmbed(client, guildId) {
|
|||||||
|
|
||||||
if (jetzt?.quelle?.art === 'youtube') {
|
if (jetzt?.quelle?.art === 'youtube') {
|
||||||
const t = jetzt.quelle.titel;
|
const t = jetzt.quelle.titel;
|
||||||
const verstrichen = Math.floor((Date.now() - jetzt.seit) / 1000);
|
// Pausen abgezogen — sonst behauptet der Balken, ein angehaltener
|
||||||
const balken = fortschritt(verstrichen, t.dauer);
|
// Titel liefe weiter, und läuft beim Fortsetzen voraus.
|
||||||
embed.setDescription(`**${kurz(t.titel, 200)}**\n`
|
const balken = fortschritt(verstrichen(jetzt), t.dauer);
|
||||||
|
embed.setDescription(`${jetzt.pausiertSeit ? '⏸️ ' : ''}**${kurz(t.titel, 200)}**\n`
|
||||||
+ `${t.kanal ? `_${kurz(t.kanal, 80)}_\n` : ''}`
|
+ `${t.kanal ? `_${kurz(t.kanal, 80)}_\n` : ''}`
|
||||||
+ (balken ? `${balken}\n` : '🔴 _Livestream_\n')
|
+ (balken ? `${balken}\n` : '🔴 _Livestream_\n')
|
||||||
+ `in <#${jetzt.channelId}>`);
|
+ `in <#${jetzt.channelId}>`);
|
||||||
@@ -107,7 +109,7 @@ export function musikEmbed(client, guildId) {
|
|||||||
// auch nicht als einer dargestellt.
|
// auch nicht als einer dargestellt.
|
||||||
const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null;
|
const heim = jetzt.heimStationId ? getStation(jetzt.heimStationId) : null;
|
||||||
embed.setDescription(`📻 Gerade läuft das Radio${heim ? ` (**${heim.name}**)` : ''}.\n`
|
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 {
|
} else {
|
||||||
embed.setDescription(liste.length > 0
|
embed.setDescription(liste.length > 0
|
||||||
? 'Nichts läuft. **Überspringen** startet die Warteschlange — '
|
? 'Nichts läuft. **Überspringen** startet die Warteschlange — '
|
||||||
@@ -149,7 +151,7 @@ export function musikKomponenten(guildId) {
|
|||||||
const reihen = [];
|
const reihen = [];
|
||||||
|
|
||||||
if (liste.length > 0) {
|
if (liste.length > 0) {
|
||||||
const optionen = (praefix) => liste.map((e, i) => {
|
const optionen = () => liste.map((e, i) => {
|
||||||
const o = new StringSelectMenuOptionBuilder()
|
const o = new StringSelectMenuOptionBuilder()
|
||||||
.setLabel(kurz(`${i + 1}. ${e.titel}`, 100))
|
.setLabel(kurz(`${i + 1}. ${e.titel}`, 100))
|
||||||
.setValue(String(e.id));
|
.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
|
// an der Radio-Tafel: ein Knopf, der sich drücken lässt und nichts
|
||||||
// bewirkt, sieht aus wie ein kaputter Knopf.
|
// bewirkt, sieht aus wie ein kaputter Knopf.
|
||||||
const nichtsDa = liste.length === 0;
|
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(
|
reihen.push(new ActionRowBuilder().addComponents(
|
||||||
knopf('musik_add', 'Einreihen…', '▶️', ButtonStyle.Success),
|
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_leeren', 'Leeren', '🗑️', ButtonStyle.Danger, nichtsDa),
|
||||||
knopf('musik_heim', 'Zum Radio', '📻', ButtonStyle.Secondary,
|
|
||||||
!jetzt || jetzt.quelle?.art !== 'youtube' || !jetzt.heimStationId),
|
|
||||||
knopf('musik_refresh', 'Aktualisieren', '🔄'),
|
knopf('musik_refresh', 'Aktualisieren', '🔄'),
|
||||||
));
|
));
|
||||||
return reihen;
|
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
|
* Drei Fälle, und der mittlere ist der, den ich am 28.08.2026 falsch hatte:
|
||||||
* naheliegende Schluss wäre „kaputt". Läuft dagegen schon etwas — Sender oder
|
*
|
||||||
* Titel —, wird nicht dazwischengefunkt: die Liste kommt dran, wenn sie dran
|
* nichts läuft → die Liste fängt an. Sonst legt jemand drei Titel hinein,
|
||||||
* ist.
|
* 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) {
|
export async function ggfAnwerfen(client, guildId, kanalId) {
|
||||||
if (laeuft(guildId) || !kanalId) return false;
|
const jetzt = laeuft(guildId);
|
||||||
const q = await weiter(client, guildId, { erzwungen: true, kanalId });
|
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);
|
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 ────────────────────────────────────────────
|
// ── Suchtreffer zwischenlagern ────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Ein Discord-Auswahlmenü gibt nur die `value` zurück, und da passt keine
|
// 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',
|
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 */
|
/** Knöpfe, Menüs und das Eingabefenster der Musik-Tafel */
|
||||||
export async function handleMusikInteraction(interaction) {
|
export async function handleMusikInteraction(interaction) {
|
||||||
@@ -402,6 +457,10 @@ export async function handleMusikInteraction(interaction) {
|
|||||||
// und das über den drei Sekunden liegt.
|
// und das über den drei Sekunden liegt.
|
||||||
await interaction.deferUpdate();
|
await interaction.deferUpdate();
|
||||||
await weiter(interaction.client, guildId, { erzwungen: true, kanalId: kanal.id });
|
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') {
|
} else if (customId === 'musik_leeren') {
|
||||||
queueClear(guildId);
|
queueClear(guildId);
|
||||||
await interaction.deferUpdate();
|
await interaction.deferUpdate();
|
||||||
|
|||||||
+100
-5
@@ -51,7 +51,7 @@ import {
|
|||||||
import { moduleEnabled } from '../modules.js';
|
import { moduleEnabled } from '../modules.js';
|
||||||
import {
|
import {
|
||||||
listStations, getStation, radioState, radioStates, setRadioState, clearRadioState, setSetting,
|
listStations, getStation, radioState, radioStates, setRadioState, clearRadioState, setSetting,
|
||||||
queueNext, queueAdd, queueVor,
|
queueNext, queueAdd, queueVor, queueStand,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
import { senderGrundstock } from './radio-sender.js';
|
import { senderGrundstock } from './radio-sender.js';
|
||||||
import { tonstrom, ytdlpPruefen, ytdlpVorhanden } from './youtube.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
|
* ihn sonst endgültig beenden. Bei einer Rohrleitung wäre dieselbe Option
|
||||||
* sinnlos — es gibt nichts, wohin sich neu verbinden liesse.
|
* 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 {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 }) {
|
function wandler(eingabe, { netz }) {
|
||||||
const ff = spawn(FFMPEG, [
|
const ff = spawn(FFMPEG, [
|
||||||
@@ -195,7 +201,8 @@ function wandler(eingabe, { netz }) {
|
|||||||
'-f', 's16le', '-acodec', 'pcm_s16le',
|
'-f', 's16le', '-acodec', 'pcm_s16le',
|
||||||
'-ar', '48000', '-ac', '2',
|
'-ar', '48000', '-ac', '2',
|
||||||
'pipe:1',
|
'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
|
// Ohne Zuhörer auf stderr läuft die Pipe voll und ffmpeg bleibt stehen
|
||||||
ff.stderr.on('data', (d) => {
|
ff.stderr.on('data', (d) => {
|
||||||
const text = d.toString().trim();
|
const text = d.toString().trim();
|
||||||
@@ -223,6 +230,17 @@ function tonquelle(quelle) {
|
|||||||
}
|
}
|
||||||
const yt = tonstrom(quelle.titel.url);
|
const yt = tonstrom(quelle.titel.url);
|
||||||
const ff = wandler('pipe:0', { netz: false });
|
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', () => {});
|
yt.stdout.on('error', () => {});
|
||||||
ff.stdin.on('error', () => {});
|
ff.stdin.on('error', () => {});
|
||||||
yt.stdout.pipe(ff.stdin);
|
yt.stdout.pipe(ff.stdin);
|
||||||
@@ -485,17 +503,80 @@ export function lautstaerkeSetzen(guildId, wunsch) {
|
|||||||
return wert;
|
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 */
|
/** Alles abräumen — Verbindung, Spieler, ffmpeg */
|
||||||
export function stoppen(guildId) {
|
export function stoppen(guildId) {
|
||||||
const s = laufend.get(guildId);
|
const s = laufend.get(guildId);
|
||||||
let abgeraeumt;
|
let abgeraeumt;
|
||||||
if (s) {
|
if (s) {
|
||||||
s.player.stop(true);
|
// Reihenfolge ist hier alles: **erst aus der Karte, dann anhalten.**
|
||||||
ketteAbraeumen(s);
|
//
|
||||||
|
// `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);
|
laufend.delete(guildId);
|
||||||
// Sonst zählt der nächste Start ab dem alten Stand weiter und bricht
|
// Sonst zählt der nächste Start ab dem alten Stand weiter und bricht
|
||||||
// womöglich sofort wieder ab.
|
// womöglich sofort wieder ab.
|
||||||
fehlschlaege.delete(guildId);
|
fehlschlaege.delete(guildId);
|
||||||
|
s.player.stop(true);
|
||||||
|
ketteAbraeumen(s);
|
||||||
// Die Statuszeile bleibt sonst stehen und behauptet stundenlang, es
|
// Die Statuszeile bleibt sonst stehen und behauptet stundenlang, es
|
||||||
// liefe ein Lied, das längst vorbei ist. Leerer Text räumt sie ab.
|
// 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.
|
// die Vorgabe ist leise, damit niemand erschrickt.
|
||||||
resource.volume?.setVolume(radioLautstaerke() / 100);
|
resource.volume?.setVolume(radioLautstaerke() / 100);
|
||||||
player.play(resource);
|
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);
|
verbindung.subscribe(player);
|
||||||
|
|
||||||
// Faengt der Ton wirklich an? Wenn ffmpeg fehlt oder der Sender nichts
|
// 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),
|
heimStationId: quelle.art === 'radio' ? quelle.station.id : (vorher?.heimStationId ?? null),
|
||||||
titel: quelle.art === 'youtube' ? quelle.titel.titel : null,
|
titel: quelle.art === 'youtube' ? quelle.titel.titel : null,
|
||||||
seit: Date.now(), tonLaeuft: null, client: kanal.client,
|
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);
|
laufend.set(guildId, eintrag);
|
||||||
// Neuer Anlauf für die Statuszeile: vielleicht wurde das Recht inzwischen
|
// 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;
|
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);
|
const tafel = radioState(guildId);
|
||||||
setRadioState({
|
setRadioState({
|
||||||
guildId,
|
guildId,
|
||||||
|
|||||||
+140
@@ -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;
|
||||||
|
}
|
||||||
+17
-4
@@ -28,7 +28,7 @@ import {
|
|||||||
createService, updateService, listServices, deleteService,
|
createService, updateService, listServices, deleteService,
|
||||||
savePage, getPage, listPages, listPublishedPages, deletePage,
|
savePage, getPage, listPages, listPublishedPages, deletePage,
|
||||||
lsState, lsModSizes, setLsModSize,
|
lsState, lsModSizes, setLsModSize,
|
||||||
listStations, createStation, updateStation, deleteStation,
|
listStations, getStation, createStation, updateStation, deleteStation,
|
||||||
queueList, queueRemove, queueClear, queueReorder, queueVor, queueStand,
|
queueList, queueRemove, queueClear, queueReorder, queueVor, queueStand,
|
||||||
setRadioState, clearRadioState, radioState,
|
setRadioState, clearRadioState, radioState,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
@@ -40,7 +40,7 @@ import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../
|
|||||||
import { istLsServer, modBasis } from '../bot/ls-farm.js';
|
import { istLsServer, modBasis } from '../bot/ls-farm.js';
|
||||||
import {
|
import {
|
||||||
titelHolen, laeuft, spielen, stoppen, weiter, radioQuelle,
|
titelHolen, laeuft, spielen, stoppen, weiter, radioQuelle,
|
||||||
lautstaerkeSetzen, ffmpegVorhanden, tafelnMelden, titelMerken,
|
lautstaerkeSetzen, ffmpegVorhanden, tafelnMelden, titelMerken, pausieren, verstrichen,
|
||||||
} from '../bot/radio.js';
|
} from '../bot/radio.js';
|
||||||
import { einreihen, eintraegeAufnehmen, ggfAnwerfen } from '../bot/radio-musik.js';
|
import { einreihen, eintraegeAufnehmen, ggfAnwerfen } from '../bot/radio-musik.js';
|
||||||
import {
|
import {
|
||||||
@@ -469,8 +469,10 @@ export function registerApiRoutes(app, client) {
|
|||||||
kanal: t?.kanal ?? null,
|
kanal: t?.kanal ?? null,
|
||||||
// Sekunden statt eines Zeitstempels: der Browser muesste sonst
|
// Sekunden statt eines Zeitstempels: der Browser muesste sonst
|
||||||
// gegen die Uhr des Servers rechnen, und die beiden gehen selten
|
// gegen die Uhr des Servers rechnen, und die beiden gehen selten
|
||||||
// gleich.
|
// gleich. `verstrichen()` zieht Pausen ab -- dieselbe Rechnung
|
||||||
verstrichen: Math.floor((Date.now() - jetzt.seit) / 1000),
|
// wie in der Discord-Tafel, nicht eine zweite daneben.
|
||||||
|
verstrichen: verstrichen(jetzt),
|
||||||
|
pausiert: Boolean(jetzt.pausiertSeit),
|
||||||
dauer: t ? t.dauer : null,
|
dauer: t ? t.dauer : null,
|
||||||
kanalId: jetzt.channelId,
|
kanalId: jetzt.channelId,
|
||||||
kanalName: kanal?.name ?? null,
|
kanalName: kanal?.name ?? null,
|
||||||
@@ -540,6 +542,17 @@ export function registerApiRoutes(app, client) {
|
|||||||
// das am Ergebnis sehen.
|
// das am Ergebnis sehen.
|
||||||
await tafelnMelden(client, guildId);
|
await tafelnMelden(client, guildId);
|
||||||
return { ok: true, lautstaerke: wert, laeuft: radioStand(guild) };
|
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') {
|
} else if (aktion === 'skip') {
|
||||||
const kanalId = zielId();
|
const kanalId = zielId();
|
||||||
if (!kanalId) return reply.code(400).send({ error: 'Kein Sprachkanal.' });
|
if (!kanalId) return reply.code(400).send({ error: 'Kein Sprachkanal.' });
|
||||||
|
|||||||
Reference in New Issue
Block a user