diff --git a/Dockerfile b/Dockerfile index a63ebc9..e6109eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,4 +35,13 @@ RUN mkdir -p /app/data ENV NODE_ENV=production +# Ohne diese Zeile weiss Docker nur, DASS der Prozess laeuft — nicht, ob der +# Bot noch mit Discord verbunden ist. Genau das beantwortet /health. +# +# `curl` und `wget` fehlen im slim-Abbild (nachgemessen), also Node selbst. +# Die Schonfrist ist grosszuegig: der Anmeldevorgang bei Discord dauert, und +# ein Neustart soll nicht als Ausfall gelten. +HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.HTTP_PORT||3080)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + CMD ["node", "src/index.js"] diff --git a/src/bot/client.js b/src/bot/client.js index d72fe1d..b34ea00 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -396,7 +396,10 @@ export async function startBot() { await command.execute(interaction); } catch (error) { console.error(`[bot] Fehler bei /${interaction.commandName}:`, error); - const reply = { content: '❌ Da ist etwas schiefgelaufen.', ephemeral: true }; + // `ephemeral: true` stand hier als einzige Stelle im ganzen Bot; + // discord.js 14 wertet es noch aus, warnt aber, und in v15 fällt + // es weg. Dann wäre diese Meldung plötzlich öffentlich. + const reply = { content: '❌ Da ist etwas schiefgelaufen.', flags: MessageFlags.Ephemeral }; if (interaction.replied || interaction.deferred) { await interaction.followUp(reply).catch(() => {}); } else { diff --git a/src/bot/radio.js b/src/bot/radio.js index 6072cb8..8c3d433 100644 --- a/src/bot/radio.js +++ b/src/bot/radio.js @@ -403,15 +403,33 @@ export function lautstaerkeSetzen(guildId, wunsch) { /** Alles abräumen — Verbindung, Spieler, ffmpeg */ export function stoppen(guildId) { const s = laufend.get(guildId); + let abgeraeumt; if (s) { s.player.stop(true); s.ffmpeg?.kill('SIGKILL'); laufend.delete(guildId); // Die Statuszeile bleibt sonst stehen und behauptet stundenlang, es - // liefe ein Lied, der längst vorbei ist. Leerer Text räumt sie ab. - kanalStatusSetzen(s.client, s.channelId, ''); + // liefe ein Lied, das längst vorbei ist. Leerer Text räumt sie ab. + // + // Das Versprechen wird zurückgegeben, nicht verworfen: beim + // Herunterfahren muss jemand darauf warten können, sonst ist der + // Prozess weg, bevor die Anfrage draussen ist. + abgeraeumt = kanalStatusSetzen(s.client, s.channelId, ''); } getVoiceConnection(guildId)?.destroy(); + return abgeraeumt; +} + +/** + * Alles anhalten — fürs geordnete Herunterfahren. + * + * `radio_state` bleibt absichtlich stehen: der Merker ist genau dafür da, + * dass der Bot nach einem Deploy von selbst zurückkommt. Abgeräumt wird nur, + * was sonst falsche Auskunft gäbe — die Statuszeile am Sprachkanal. + */ +export async function radioBeenden() { + // Erst eine Kopie der Schlüssel: `stoppen` löscht aus derselben Karte. + await Promise.allSettled([...laufend.keys()].map((guildId) => stoppen(guildId))); } /** diff --git a/src/db.js b/src/db.js index ee090c5..8115b89 100644 --- a/src/db.js +++ b/src/db.js @@ -11,6 +11,17 @@ mkdirSync(dirname(dbFile), { recursive: true }); export const db = new Database(dbFile); db.pragma('journal_mode = WAL'); +/** + * Antwortet die Datenbank überhaupt? Für `/health`. + * + * Bewusst eine echte Abfrage und nicht `existsSync` auf die Datei: die Datei + * ist auch dann noch da, wenn das Dateisystem nur noch lesbar ist oder die + * Verbindung hinüber ist. Einmal vorbereitet, damit die Prüfung selbst nichts + * kostet. + */ +const pingStmt = db.prepare('SELECT 1 AS eins'); +export const pingDb = () => pingStmt.get()?.eins === 1; + db.exec(` CREATE TABLE IF NOT EXISTS commits ( sha TEXT PRIMARY KEY, diff --git a/src/index.js b/src/index.js index 37c9b7e..0ec5fa2 100644 --- a/src/index.js +++ b/src/index.js @@ -6,7 +6,7 @@ import { startWatchdog } from './bot/watchdog.js'; import { scheduleBackups } from './backup.js'; import { startServerMonitor } from './bot/server-monitor.js'; import { startLsFarm } from './bot/ls-farm.js'; -import { startRadio } from './bot/radio.js'; +import { startRadio, radioBeenden } from './bot/radio.js'; import { startGiveaways } from './bot/giveaways.js'; import { startScheduledPosts } from './web/scheduled-posts.js'; import { startSocialNotify } from './bot/social-notify.js'; @@ -17,9 +17,59 @@ process.on('unhandledRejection', (error) => { console.error('[main] Unhandled Rejection:', error); }); +let client = null; +let app = null; + +/** + * Geordnet herunterfahren. + * + * Ohne das räumt `docker stop` den Prozess nach der Schonfrist einfach ab. + * Zwei Dinge geben dann falsche Auskunft: der Bot steht in Discord noch eine + * Weile als online, und die Statuszeile am Sprachkanal behauptet weiter, es + * liefe Musik. Beides überlebt den Container, der es geschrieben hat. + * + * Reihenfolge ist wichtig: erst das Radio, denn zum Abräumen der Statuszeile + * braucht es die Verbindung zu Discord noch. + */ +let laeuftAus = false; +async function beenden(signal) { + if (laeuftAus) return; // zweites SIGTERM nicht doppelt abarbeiten + laeuftAus = true; + console.log(`[main] ${signal} — fahre herunter`); + + // Docker wartet vorgabemässig zehn Sekunden und schiesst dann. Lieber + // vorher selbst aufhören, als mitten im Aufräumen abgeschossen zu werden. + const frist = setTimeout(() => { + console.error('[main] Herunterfahren dauert zu lange — breche ab'); + process.exit(1); + }, 8000); + frist.unref?.(); + + for (const [was, tun] of [ + ['Radio', () => radioBeenden()], + ['Webserver', () => app?.close()], + ['Discord', () => client?.destroy()], + ]) { + try { + await tun(); + } catch (error) { + // Ein Fehler beim Aufräumen darf das restliche Aufräumen nicht + // verhindern — sonst bleibt wegen der Statuszeile der Bot online. + console.error(`[main] ${was} beim Herunterfahren:`, error.message); + } + } + + clearTimeout(frist); + console.log('[main] beendet'); + process.exit(0); +} + +process.on('SIGTERM', () => beenden('SIGTERM')); +process.on('SIGINT', () => beenden('SIGINT')); + try { - const client = await startBot(); - await startWebServer(client); + client = await startBot(); + app = await startWebServer(client); scheduleWeeklyRecap(client); startWatchdog(client); scheduleBackups(client); diff --git a/src/web/server.js b/src/web/server.js index 778c4c6..330c1f5 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -8,7 +8,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { config } from '../config.js'; -import { saveCommits, saveRelease, takeBugReport, getDevlog, getPage } from '../db.js'; +import { saveCommits, saveRelease, takeBugReport, getDevlog, getPage, pingDb } from '../db.js'; import { commitFeedEnabled, branchAllowed, repoIgnored, publicUrl, brandName, hubUrl, botUrl } from '../runtime-settings.js'; import { postPushEmbed } from '../bot/commit-feed.js'; import { postReleaseEmbed } from '../bot/release-feed.js'; @@ -75,7 +75,34 @@ export async function startWebServer(client) { registerLinkedRoleRoutes(app, client); // Healthcheck (für Portainer/NPM) - app.get('/health', async () => ({ status: 'ok' })); + // + // Hier stand ein fester Wert: `{ status: 'ok' }`, immer, ohne etwas + // anzusehen. Das ist keine Prüfung — es meldet „sauber", solange der + // HTTP-Faden läuft, und der läuft auch dann noch, wenn die Verbindung zu + // Discord längst weg ist. Ein Bot ohne Gateway ist für alle draussen + // genauso weg wie ein abgestürzter, nur merkt es niemand. + // + // Geprüft wird deshalb, was von aussen zählt: steht die Verbindung, und + // antwortet die Datenbank. Beides weiss der Prozess ohnehin — der + // Herzschlag schreibt `isReady()` alle zwei Minuten mit. + app.get('/health', async (request, reply) => { + const gruende = []; + if (!client?.isReady?.()) gruende.push('keine Verbindung zu Discord'); + try { + if (!pingDb()) gruende.push('Datenbank antwortet unerwartet'); + } catch (error) { + gruende.push(`Datenbank antwortet nicht: ${error.message}`); + } + if (gruende.length > 0) { + // 503 und nicht 500: das ist kein Programmfehler, sondern ein + // Zustand, der vorbeigehen kann. Der Grund steht dabei, sonst + // fängt das Raten wieder von vorn an. + return reply.code(503).send({ status: 'gestoert', gruende }); + } + // -1 heisst „noch nicht gemessen"; das ist kein Fehler, nur unbekannt + const ping = client.ws?.ping; + return { status: 'ok', discord: { ping: ping >= 0 ? ping : null } }; + }); // Lokal gespeicherte Devlog-Bilder (Discord-CDN-Links laufen ab) await app.register(fastifyStatic, {