Compare commits
11
Commits
radio-youtube
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00e4774a5f | ||
|
|
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.
|
||||
# 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=
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
+76
-17
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
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.' });
|
||||
|
||||
Reference in New Issue
Block a user