Files

357 lines
11 KiB
JavaScript

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));