diff --git a/src/bot/radio.js b/src/bot/radio.js index 1b6e1dd..8fb4bc8 100644 --- a/src/bot/radio.js +++ b/src/bot/radio.js @@ -165,6 +165,11 @@ function beobachteterAdapter(guild, schritte) { }, onVoiceServerUpdate: (daten) => { schritte.sprachserver = true; + // Ein leerer Endpunkt ist kein Versehen: Discord schickt das, + // wenn es den Sprachserver gerade neu zuteilt. Die Bibliothek + // wartet dann auf die zweite Meldung — kommt die nie, bleibt + // sie fuer immer bei "signalling" stehen, ohne Fehler. + schritte.endpunkt = daten?.endpoint ?? null; return methods.onVoiceServerUpdate(daten); }, }; @@ -197,7 +202,23 @@ export function beitrittsDiagnose(schritte) { return 'Der Sprachserver steht, aber der eigene Sprachstatus kam nie an — ' + 'dem Bot fehlt der Intent **GuildVoiceStates**.'; } - return 'Der Austausch mit dem Sprachserver kam nicht zu Ende — ausgehendes UDP dicht?'; + // Ab hier ist alles angekommen. Jetzt entscheidet der Weg, den die + // Verbindung genommen hat. + if (schritte.endpunkt === null) { + return 'Discord hat den Beitritt bestätigt, aber keinen Sprachserver zugeteilt. ' + + 'Das passiert bei einem Regionswechsel — im Kanal unter *Region überschreiben* ' + + 'einmal eine feste Region wählen.'; + } + const kamAnsNetz = (schritte.weg ?? []).includes('connecting'); + if (!kamAnsNetz) { + return `Der Sprachserver (${schritte.endpunkt ?? '?'}) wurde genannt, aber die Verbindung ` + + 'kam nie bis zum Netzwerk-Schritt.'; + } + // Sie war beim Netzwerk-Schritt und ist zurückgefallen: der Austausch mit + // dem Sprachserver selbst scheitert. Das ist UDP, und zwar ausgehend. + return `Der Austausch mit dem Sprachserver **${schritte.endpunkt}** scheitert. ` + + 'Discord-Sprache braucht **ausgehendes UDP** (Zielports 50000–65535) — ' + + 'wenn nur TCP rausdarf, sieht es genau so aus.'; } /** Läuft gerade etwas auf diesem Server? */ @@ -226,7 +247,10 @@ export async function spielen(kanal, station) { const vorher = laufend.get(guildId); // Wird nur gefüllt, wenn wirklich neu verbunden wird - const schritte = { angefragt: null, eigenerStatus: false, sprachserver: false }; + const schritte = { + angefragt: null, eigenerStatus: false, sprachserver: false, + endpunkt: undefined, weg: [], + }; let verbindung = getVoiceConnection(guildId); if (!verbindung || verbindung.joinConfig.channelId !== kanal.id) { @@ -237,6 +261,13 @@ export async function spielen(kanal, station) { adapterCreator: beobachteterAdapter(kanal.guild, schritte), selfDeaf: true, // der Bot muss nicht hören, nur senden }); + // Der Weg, den die Verbindung nimmt, unterscheidet zwei Sackgassen, + // die von aussen gleich aussehen: nie über „signalling" hinaus (dann + // fehlt der Sprachserver) oder immer wieder dorthin zurück (dann + // scheitert der UDP-Austausch und die Bibliothek fängt von vorn an). + verbindung.on('stateChange', (_, neuerStand) => { + if (schritte.weg.at(-1) !== neuerStand.status) schritte.weg.push(neuerStand.status); + }); // Discord verschiebt Sprachserver im Betrieb; ohne dieses Zurückholen // bleibt die Verbindung dann still stehen, statt sich zu erholen. // @@ -283,7 +314,8 @@ export async function spielen(kanal, station) { const grund = beitrittsDiagnose(schritte); console.error(`[radio] Beitritt steckt bei "${steckt}" — ` + `angefragt: ${schritte.angefragt}, eigener Status: ${schritte.eigenerStatus}, ` - + `Sprachserver: ${schritte.sprachserver}`); + + `Sprachserver: ${schritte.sprachserver}, Endpunkt: ${schritte.endpunkt ?? '—'}, ` + + `Weg: ${(schritte.weg ?? []).join(' → ') || '(keiner)'}`); throw new Error(`Sprachverbindung kam nicht zustande. ${grund}`); } diff --git a/tools/udp-pruefen.mjs b/tools/udp-pruefen.mjs new file mode 100644 index 0000000..ac6dec1 --- /dev/null +++ b/tools/udp-pruefen.mjs @@ -0,0 +1,96 @@ +// Kommt UDP aus diesem Container heraus? +// +// Discord-Sprache läuft über UDP auf hohen Zielports. Ist das dicht, sitzt +// der Bot sichtbar im Sprachkanal und schweigt — im Log steht dann +// „Der Austausch mit dem Sprachserver scheitert". +// +// Aufruf im Container: +// docker exec -it node tools/udp-pruefen.mjs +// +// Geprüft wird in zwei Stufen, weil das den Unterschied ausmacht: +// 1. UDP auf Port 53 (DNS) — geht überhaupt UDP raus? +// 2. UDP auf einen hohen Port (STUN, 19302) — oder nur die üblichen? +// +// Beide grün und der Bot schweigt trotzdem? Dann liegt es nicht am UDP. +import dgram from 'node:dgram'; + +/** Ein Paket hinschicken und auf Antwort warten */ +function versuch({ host, port, paket, timeoutMs = 4000 }) { + return new Promise((fertig) => { + const sock = dgram.createSocket('udp4'); + const start = Date.now(); + const schluss = (ergebnis) => { + clearTimeout(timer); + try { sock.close(); } catch { /* schon zu */ } + fertig(ergebnis); + }; + const timer = setTimeout(() => schluss({ ok: false, grund: 'keine Antwort' }), timeoutMs); + sock.on('message', () => schluss({ ok: true, ms: Date.now() - start })); + sock.on('error', (e) => schluss({ ok: false, grund: e.message })); + sock.send(paket, port, host, (e) => { + if (e) schluss({ ok: false, grund: `Senden ging nicht: ${e.message}` }); + }); + }); +} + +// Eine DNS-Anfrage nach example.com — kürzestmöglich von Hand gebaut +const dnsPaket = Buffer.concat([ + Buffer.from([0x12, 0x34, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0]), + Buffer.from([7]), Buffer.from('example'), + Buffer.from([3]), Buffer.from('com'), + Buffer.from([0, 0, 1, 0, 1]), +]); + +// Eine STUN-Bindungsanfrage: 20 Byte Kopf, fester Magic Cookie +const stunPaket = Buffer.concat([ + Buffer.from([0x00, 0x01, 0x00, 0x00]), + Buffer.from([0x21, 0x12, 0xa4, 0x42]), + Buffer.from('d4rkbotudpprue'.slice(0, 12)), +]); + +const proben = [ + { name: 'UDP raus (DNS)', host: '1.1.1.1', port: 53, paket: dnsPaket }, + { name: 'UDP raus (DNS, zweiter)', host: '8.8.8.8', port: 53, paket: dnsPaket }, + { name: 'UDP auf hohem Port (STUN)', host: 'stun.l.google.com', port: 19302, paket: stunPaket }, + { name: 'UDP auf hohem Port (STUN 2)', host: 'stun1.l.google.com', port: 19302, paket: stunPaket }, +]; + +console.log('Prüfe ausgehendes UDP …\n'); +const ergebnisse = []; +for (const p of proben) { + const r = await versuch(p); + ergebnisse.push({ ...p, ...r }); + console.log(` ${r.ok ? '✅' : '❌'} ${p.name.padEnd(28)} ${p.host}:${p.port} ` + + (r.ok ? `${r.ms} ms` : r.grund)); +} + +const niedrig = ergebnisse.filter((r) => r.port < 1024).some((r) => r.ok); +const hoch = ergebnisse.filter((r) => r.port >= 1024).some((r) => r.ok); + +console.log(''); +if (hoch) { + console.log('UDP kommt auf hohen Ports raus. Wenn das Radio trotzdem schweigt,'); + console.log('liegt es nicht an ausgehendem UDP — dann den Endpunkt aus dem Log'); + console.log('mit dem nächsten Abschnitt prüfen.'); +} else if (niedrig) { + console.log('UDP geht nur auf niedrigen Ports raus. Genau das bricht Discord-Sprache:'); + console.log('die Sprachserver antworten auf hohen Ports. In der Firewall ausgehendes'); + console.log('UDP zu 50000–65535 freigeben.'); +} else { + console.log('Es kommt gar kein UDP heraus. Discord-Sprache kann so nicht funktionieren.'); + console.log('Ausgehendes UDP in der Firewall des Rootservers freigeben.'); +} + +// Endpunkt aus dem Log mitgeben: node tools/udp-pruefen.mjs frankfurt1234.discord.media +const ziel = process.argv[2]; +if (ziel) { + console.log(`\nZusätzlich: ${ziel} auf Port 50000 …`); + // Discord antwortet auf ein Zufallspaket nicht — hier zählt nur, ob das + // Senden selbst durchgeht und ob eine Fehlermeldung zurückkommt. + const r = await versuch({ host: ziel, port: 50000, paket: Buffer.alloc(74), timeoutMs: 3000 }); + console.log(r.ok + ? ' Antwort erhalten (ungewöhnlich, aber gut).' + : ` ${r.grund === 'keine Antwort' + ? 'keine Antwort — das ist hier normal, ein Fehler waere aussagekräftiger' + : `Fehler: ${r.grund}`}`); +}