357 lines
11 KiB
JavaScript
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));
|