diff --git a/Dockerfile b/Dockerfile index 3a60289..a63ebc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,10 @@ COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY src ./src +# Die Prüfwerkzeuge müssen dort laufen, wo der Fehler auftritt — im Container. +# Ohne diese Zeile lief `docker exec … node tools/udp-pruefen.mjs` ins Leere, +# obwohl genau das im Kopf des Skripts steht. +COPY tools ./tools COPY --from=frontend /build/dist ./frontend/dist # Laufzeitdaten (SQLite) landen in /app/data → Volume diff --git a/src/bot/radio.js b/src/bot/radio.js index 8fb4bc8..729c365 100644 --- a/src/bot/radio.js +++ b/src/bot/radio.js @@ -169,7 +169,12 @@ function beobachteterAdapter(guild, schritte) { // 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; + // + // Gesammelt statt ueberschrieben: bei einem Rueckfall nach + // "signalling" nennt Discord den Server erneut. Vorher hat die + // zweite Meldung den Endpunkt verdeckt, an dem es wirklich + // gescheitert ist. + schritte.endpunkte.push(daten?.endpoint ?? null); return methods.onVoiceServerUpdate(daten); }, }; @@ -185,7 +190,35 @@ function beobachteterAdapter(guild, schritte) { }; } -/** Aus den drei Häkchen wird ein Satz, der sagt, was zu tun ist */ +/** + * Die Schritte des Netzteils der Bibliothek, in ihrer eigenen Zählung. + * + * Nachgesehen in `@discordjs/voice` 0.18 (`NetworkingStatusCode`), nicht + * geraten. Genau hier liegt der Unterschied, den `connecting → signalling` + * verschweigt: die Verbindung fällt bei *jedem* Websocket-Schluss ausser 4014 + * stumm dorthin zurück, egal ob der UDP-Teil je begonnen hat. + */ +const NETZSCHRITT = ['Websocket auf', 'Anmeldung', 'UDP-Handschlag', 'Protokollwahl', + 'bereit', 'Wiederaufnahme', 'zu']; +const ANMELDUNG = 1; +const UDP_HANDSCHLAG = 2; +const PROTOKOLLWAHL = 3; + +// Damit dasselbe Netzteil nicht zweimal beobachtet wird. Bei einem Rückfall +// baut die Bibliothek ein neues — das soll durchaus wieder mitgeschrieben +// werden, deshalb sitzt die Markierung am Objekt und nicht an der Verbindung. +const BEOBACHTET = Symbol('radio-netzteil-beobachtet'); + +/** Der Netz-Mitschnitt als lesbare Zeile */ +export function netzWeg(netz = []) { + return netz + .map((e) => (e.schliessung !== undefined + ? `Websocket zu (${e.schliessung})` + : NETZSCHRITT[e.schritt] ?? `Schritt ${e.schritt}`)) + .join(' → '); +} + +/** Aus den Häkchen und dem Mitschnitt wird ein Satz, der sagt, was zu tun ist */ export function beitrittsDiagnose(schritte) { if (schritte.angefragt === false) { return 'Die Verbindung zu Discord war nicht bereit — der Beitritt ging gar nicht erst raus.'; @@ -204,21 +237,54 @@ export function beitrittsDiagnose(schritte) { } // Ab hier ist alles angekommen. Jetzt entscheidet der Weg, den die // Verbindung genommen hat. - if (schritte.endpunkt === null) { + const endpunkte = schritte.endpunkte ?? []; + const endpunkt = endpunkte.find(Boolean) ?? null; + if (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 ` + return `Der Sprachserver (${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.'; + + // Sie war beim Netzwerk-Schritt und ist zurückgefallen. *Wo* sie dort + // stehen blieb, ist die eigentliche Auskunft — und die stand hier vorher + // nicht, weshalb an dieser Stelle „ausgehendes UDP dicht" behauptet wurde. + // Gemessen am 14.08.2026: UDP kam auf hohen Ports sehr wohl raus (STUN + // 19302 antwortet, TCP 2096 zum Sprachserver offen). Die Behauptung war + // also falsch. Jetzt entscheidet der Mitschnitt statt einer Vermutung. + const netz = schritte.netz ?? []; + const letzterSchritt = netz.filter((e) => e.schritt !== undefined).at(-1)?.schritt; + const schluss = netz.filter((e) => e.schliessung !== undefined).at(-1)?.schliessung; + const zu = schluss === undefined ? 'ohne Schliesscode' : `Schliesscode ${schluss}`; + + if (letzterSchritt === UDP_HANDSCHLAG) { + // `performIPDiscovery` hat in 0.18 keine Zeitschranke und die + // UDP-Keepalives werden nicht mehr ausgewertet — bleibt die Antwort + // aus, hängt der Schritt, bis Discord den Websocket zumacht. + return `Der Sprachserver **${endpunkt}** wurde erreicht, aber auf den UDP-Handschlag ` + + `kam keine Antwort (${zu}). Discord-Sprache antwortet auf hohen Ports ` + + '(50000–65535) — ausgehendes UDP dorthin freigeben. ' + + 'Gegenprobe: `node tools/udp-pruefen.mjs`.'; + } + if (letzterSchritt === ANMELDUNG || letzterSchritt === 0) { + return `Der Websocket zu **${endpunkt}** ging wieder zu, bevor der UDP-Teil überhaupt ` + + `begann (${zu}). Das ist kein Netzproblem: 4006 heisst ungültige Sitzung, ` + + '4009 Zeitüberschreitung, 4011 unbekannter Server.'; + } + if (letzterSchritt === PROTOKOLLWAHL) { + return `Der UDP-Handschlag mit **${endpunkt}** ging durch, aber Discord schickte keine ` + + `Sitzungsbeschreibung (${zu}). Das deutet auf den Verschlüsselungsmodus — ` + + '`generateDependencyReport()` zeigt, welche Bibliothek geladen ist.'; + } + // Kein Mitschnitt vom Netzteil: dann ist die Ursache offen, und genau das + // wird gesagt. Eine Prüfung, die nichts sehen kann, meldet nicht "UDP". + return `Die Verbindung zu **${endpunkt}** kam bis zum Netzwerk-Schritt und fiel zurück, ` + + `aber wo genau, ist nicht erfasst (${netzWeg(netz) || 'kein Mitschnitt'}). ` + + 'Ursache offen.'; } /** Läuft gerade etwas auf diesem Server? */ @@ -249,7 +315,7 @@ export async function spielen(kanal, station) { // Wird nur gefüllt, wenn wirklich neu verbunden wird const schritte = { angefragt: null, eigenerStatus: false, sprachserver: false, - endpunkt: undefined, weg: [], + endpunkte: [], weg: [], netz: [], }; let verbindung = getVoiceConnection(guildId); @@ -268,6 +334,28 @@ export async function spielen(kanal, station) { verbindung.on('stateChange', (_, neuerStand) => { if (schritte.weg.at(-1) !== neuerStand.status) schritte.weg.push(neuerStand.status); }); + // Der Weg allein reicht nicht: er sagt „connecting → signalling", aber + // nicht, ob der UDP-Teil je begonnen hat. Das weiss nur das Netzteil + // der Bibliothek, und das meldet seinen Fortschritt ausschliesslich + // über `debug` — was Token und Sitzungsschlüssel mitdruckt. Deshalb + // wird hier nur die Schrittnummer mitgeschrieben, nie die Nachricht. + verbindung.on(VoiceConnectionStatus.Connecting, () => { + const netzteil = verbindung.state.networking; + if (!netzteil || netzteil[BEOBACHTET]) return; + netzteil[BEOBACHTET] = true; + schritte.netz.push({ schritt: netzteil.state.code }); + netzteil.on('stateChange', (_, neu) => { + if (schritte.netz.at(-1)?.schritt !== neu.code) schritte.netz.push({ schritt: neu.code }); + }); + // Den Schliesscode wirft die Bibliothek weg: ausser bei 4014 geht + // sie damit stumm zurück nach „signalling". Hier bleibt er stehen. + netzteil.on('close', (code) => schritte.netz.push({ schliessung: code })); + }); + // Ohne diesen Hörer beendet ein Fehler aus dem Netzteil den ganzen + // Prozess: `VoiceConnection` reicht ihn als 'error' weiter, und ein + // EventEmitter ohne 'error'-Hörer wirft. Genau dieser Weg ist im + // Fehlerfall offen — die UDP-Erkennung meldet sich darüber. + verbindung.on('error', (e) => console.error('[radio] Verbindung:', e.message)); // Discord verschiebt Sprachserver im Betrieb; ohne dieses Zurückholen // bleibt die Verbindung dann still stehen, statt sich zu erholen. // @@ -314,8 +402,10 @@ 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}, Endpunkt: ${schritte.endpunkt ?? '—'}, ` - + `Weg: ${(schritte.weg ?? []).join(' → ') || '(keiner)'}`); + + `Sprachserver: ${schritte.sprachserver}, ` + + `Endpunkt: ${schritte.endpunkte.map((e) => e ?? '(leer)').join(', ') || '—'}, ` + + `Weg: ${(schritte.weg ?? []).join(' → ') || '(keiner)'}, ` + + `Netzteil: ${netzWeg(schritte.netz) || '(nie erreicht)'}`); throw new Error(`Sprachverbindung kam nicht zustande. ${grund}`); }