const { Client, LocalAuth } = require('whatsapp-web.js'); const express = require('express'); const qrcode = require('qrcode'); const fs = require('fs-extra'); const path = require('path'); const PORT = 3000; const SESSION_DATA = '/data/auth'; const CLIENT_ID = 'ha_bridge_session'; const RECONNECT_BASE_DELAY_MS = 2_000; const RECONNECT_MAX_DELAY_MS = 60_000; const HEALTH_CHECK_INTERVAL_MS = 30_000; const HEALTH_CHECK_FAILURE_LIMIT = 3; const app = express(); app.use(express.json()); app.use(express.static(path.join(__dirname, 'ui'))); let client = null; let server = null; let reconnectTimer = null; let reconnectAttempts = 0; let healthCheckFailures = 0; let clientGeneration = 0; let isReady = false; let isInitializing = true; let isShuttingDown = false; let lastQr = ''; let lastError = null; function errorMessage(error) { if (error instanceof Error) return error.message; if (typeof error === 'string') return error; try { return JSON.stringify(error); } catch (_error) { return String(error); } } function findChromium() { const candidates = [ process.env.CHROME_BIN, '/usr/bin/chromium-browser', '/usr/bin/chromium' ].filter(Boolean); return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0]; } // A container restart can leave Chromium lock files in the persistent profile. function removeStaleChromiumLocks() { const sessionFolder = path.join(SESSION_DATA, `session-${CLIENT_ID}`); for (const lockName of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) { const lockPath = path.join(sessionFolder, lockName); try { if (fs.existsSync(lockPath)) fs.removeSync(lockPath); } catch (error) { console.warn(`Veraltete Chromium-Sperre ${lockName} konnte nicht entfernt werden: ${errorMessage(error)}`); } } } function createClient() { return new Client({ authStrategy: new LocalAuth({ dataPath: SESSION_DATA, clientId: CLIENT_ID }), authTimeoutMs: 120_000, // 0 disables the limit. QR codes rotate regularly while waiting for a scan; // this is not a connection failure and must never restart the add-on. qrMaxRetries: 0, takeoverOnConflict: true, takeoverTimeoutMs: 10_000, puppeteer: { executablePath: findChromium(), protocolTimeout: 120_000, headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-extensions', '--no-first-run', '--no-default-browser-check' ] } }); } async function destroyClient(instance) { if (!instance) return; instance.removeAllListeners(); try { await instance.destroy(); } catch (error) { console.warn(`WhatsApp Client konnte nicht vollständig beendet werden: ${errorMessage(error)}`); try { if (instance.pupBrowser) await instance.pupBrowser.close(); } catch (_error) { // The browser is already gone. } } } function scheduleReconnect(reason) { if (isShuttingDown || reconnectTimer) return; isReady = false; isInitializing = false; lastQr = ''; lastError = errorMessage(reason); const delay = Math.min( RECONNECT_BASE_DELAY_MS * (2 ** reconnectAttempts), RECONNECT_MAX_DELAY_MS ); reconnectAttempts += 1; console.warn(`WhatsApp Verbindung unterbrochen: ${lastError}. Neuer Versuch in ${Math.round(delay / 1000)} s.`); reconnectTimer = setTimeout(() => { reconnectTimer = null; initializeClient().catch((error) => scheduleReconnect(error)); }, delay); } async function initializeClient() { if (isShuttingDown) return; const generation = ++clientGeneration; const previousClient = client; client = null; await destroyClient(previousClient); removeStaleChromiumLocks(); if (isShuttingDown || generation !== clientGeneration) return; const instance = createClient(); client = instance; isReady = false; isInitializing = true; lastQr = ''; const isCurrent = () => !isShuttingDown && client === instance && generation === clientGeneration; instance.on('qr', (qr) => { if (!isCurrent()) return; lastQr = qr; isReady = false; isInitializing = false; lastError = null; console.log('Neuer QR Code generiert. Bitte im Add-on-Webinterface scannen.'); }); instance.on('authenticated', () => { if (!isCurrent()) return; lastQr = ''; isInitializing = true; lastError = null; console.log('WhatsApp Anmeldung erfolgreich. Verbindung wird aufgebaut...'); }); instance.on('ready', () => { if (!isCurrent()) return; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } isReady = true; isInitializing = false; lastQr = ''; lastError = null; reconnectAttempts = 0; healthCheckFailures = 0; console.log('WhatsApp ist bereit!'); }); instance.on('auth_failure', (message) => { if (!isCurrent()) return; console.error(`WhatsApp Authentifizierung fehlgeschlagen: ${errorMessage(message)}`); scheduleReconnect(`Authentifizierung fehlgeschlagen: ${errorMessage(message)}`); }); instance.on('disconnected', (reason) => { if (!isCurrent()) return; scheduleReconnect(reason || 'Unbekannter Verbindungsabbruch'); }); console.log(`Initialisiere WhatsApp Client (Chromium: ${findChromium()})...`); try { await instance.initialize(); } catch (error) { if (isCurrent()) scheduleReconnect(`Initialisierungsfehler: ${errorMessage(error)}`); } } function isBrowserError(error) { const message = errorMessage(error); return [ 'ProtocolError', 'Runtime.callFunctionOn', 'Target closed', 'Session closed', 'Execution context was destroyed' ].some((fragment) => message.includes(fragment)); } process.on('uncaughtException', (error) => { if (isBrowserError(error)) { scheduleReconnect(`Chromium-Fehler: ${errorMessage(error)}`); return; } console.error('Nicht behandelter Fehler:', error); shutdown(1); }); process.on('unhandledRejection', (reason) => { if (isBrowserError(reason)) { scheduleReconnect(`Chromium-Fehler: ${errorMessage(reason)}`); return; } console.error('Nicht behandelte Promise-Ablehnung:', reason); }); // whatsapp-web.js normally emits "disconnected". Chromium can occasionally // become unresponsive without emitting it, so verify the live state as well. const healthCheckTimer = setInterval(async () => { if (!isReady || !client || isShuttingDown || reconnectTimer) return; const checkedClient = client; try { const state = await checkedClient.getState(); if (client !== checkedClient) return; if (state === 'CONNECTED') { healthCheckFailures = 0; return; } healthCheckFailures += 1; console.warn(`WhatsApp Zustandsprüfung ${healthCheckFailures}/${HEALTH_CHECK_FAILURE_LIMIT}: ${state || 'keine Antwort'}`); } catch (error) { if (client !== checkedClient) return; healthCheckFailures += 1; console.warn(`WhatsApp Zustandsprüfung ${healthCheckFailures}/${HEALTH_CHECK_FAILURE_LIMIT} fehlgeschlagen: ${errorMessage(error)}`); } if (healthCheckFailures >= HEALTH_CHECK_FAILURE_LIMIT) { healthCheckFailures = 0; scheduleReconnect('Verbindung reagiert seit 90 Sekunden nicht'); } }, HEALTH_CHECK_INTERVAL_MS); healthCheckTimer.unref(); app.get('/', (_req, res) => { res.sendFile(path.join(__dirname, 'ui', 'index.html')); }); app.get('/api/health', (_req, res) => { res.json({ status: 'ok' }); }); app.get('/api/status', async (_req, res) => { try { const qrData = lastQr ? await qrcode.toDataURL(lastQr) : null; let clientState = isInitializing ? 'INITIALIZING' : 'DISCONNECTED'; if (isReady && client) clientState = await client.getState().catch(() => 'ERROR'); res.json({ isReady, hasQr: Boolean(lastQr), qrCode: qrData, isInitializing, clientState, reconnectAttempts, lastError }); } catch (error) { res.status(500).json({ error: errorMessage(error) }); } }); app.post('/auth/logout', async (_req, res) => { try { console.log('Logout über Web-UI angefordert...'); isReady = false; clientGeneration += 1; const oldClient = client; client = null; if (oldClient) await oldClient.logout().catch(() => {}); await destroyClient(oldClient); await fs.remove(SESSION_DATA); reconnectAttempts = 0; res.json({ success: true, message: 'Session gelöscht. Eine neue Anmeldung wird vorbereitet.' }); setTimeout(() => initializeClient().catch((error) => scheduleReconnect(error)), 1_000); } catch (error) { res.status(500).json({ error: errorMessage(error) }); } }); app.delete('/system/reset', async (_req, res) => { try { console.log('Werkseinstellungen über Web-UI angefordert...'); isReady = false; clientGeneration += 1; const oldClient = client; client = null; await destroyClient(oldClient); // Only remove bridge-owned data. options.json belongs to Home Assistant. await fs.remove(SESSION_DATA); await fs.remove('/data/.wwebjs_cache'); reconnectAttempts = 0; res.json({ success: true, message: 'WhatsApp-Session und Web-Cache gelöscht. Neue Anmeldung wird vorbereitet.' }); setTimeout(() => initializeClient().catch((error) => scheduleReconnect(error)), 1_000); } catch (error) { res.status(500).json({ error: errorMessage(error) }); } }); app.post('/send', async (req, res) => { const { number, message } = req.body; if (!number || typeof message !== 'string') { return res.status(400).json({ error: 'number und message sind erforderlich' }); } if (!isReady || !client) return res.status(503).json({ error: 'Client nicht bereit' }); try { const chatId = number.includes('@c.us') ? number : `${number}@c.us`; await client.sendMessage(chatId, message); return res.json({ success: true }); } catch (error) { return res.status(500).json({ error: errorMessage(error) }); } }); async function shutdown(exitCode = 0) { if (isShuttingDown) return; isShuttingDown = true; if (reconnectTimer) clearTimeout(reconnectTimer); clearInterval(healthCheckTimer); console.log('WhatsApp Bridge wird sauber beendet...'); if (server) await new Promise((resolve) => server.close(resolve)); const oldClient = client; client = null; await destroyClient(oldClient); process.exit(exitCode); } process.on('SIGTERM', () => shutdown(0)); process.on('SIGINT', () => shutdown(0)); server = app.listen(PORT, () => console.log(`WhatsApp Bridge Server läuft auf Port ${PORT}`)); initializeClient().catch((error) => scheduleReconnect(error));