2 Commits
Author SHA1 Message Date
bahmcloud fe86f77205 Stabilisiere WhatsApp-Verbindung 2026-08-04 13:00:06 +02:00
bahmcloud 125e62d4f8 whatsapp_bridge/CHANGELOG.md aktualisiert 2026-06-15 08:45:59 +00:00
9 changed files with 3871 additions and 144 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
npm-debug.log*
@@ -1,5 +1,12 @@
# CHANGELOG - Whatsapp Bridge Addon
## Release 1.4.0 - 2026-08-04
- Fixed the QR retry restart loop; QR codes now remain available until scanned.
- Added automatic client recovery with bounded exponential backoff.
- Added a connection health check that recovers silent Chromium/network hangs.
- Updated whatsapp-web.js and the Alpine/Chromium runtime for current Home Assistant versions.
- Added clean container shutdown and safer session reset behavior.
## Release 1.3.2 - 2026-06-15
- Fix "Freezing State" and some other improvements
+3 -3
View File
@@ -1,4 +1,4 @@
FROM alpine:3.19
FROM alpine:3.22
RUN apk add --no-cache \
nodejs \
@@ -13,13 +13,13 @@ RUN apk add --no-cache \
bash \
curl
ENV CHROME_BIN=/usr/bin/chromium-browser \
ENV CHROME_BIN=/usr/bin/chromium \
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
WORKDIR /app
COPY package.json ./
RUN npm install --production
RUN npm install --omit=dev
COPY . .
+1 -6
View File
@@ -1,6 +1,6 @@
name: "WhatsApp Bridge"
description: "WhatsApp Gateway with Management UI for Home Assistant"
version: "1.3.2"
version: "1.4.0"
slug: "whatsapp_bridge"
arch:
- aarch64
@@ -9,11 +9,6 @@ init: false
ingress: true
ingress_port: 3000
panel_icon: "mdi:whatsapp"
map:
- config:rw
- share:rw
- ssl:ro
- data:rw
options:
log_level: "info"
schema:
+3535
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,12 +1,15 @@
{
"name": "whatsapp-bridge",
"version": "1.3.2",
"version": "1.4.0",
"description": "WhatsApp bridge for Home Assistant",
"main": "server.js",
"dependencies": {
"express": "^4.18.2",
"whatsapp-web.js": "^1.23.0",
"whatsapp-web.js": "1.34.7",
"qrcode": "^1.5.3",
"fs-extra": "^11.1.1"
"fs-extra": "^11.3.1"
},
"scripts": {
"check": "node --check server.js"
}
}
+2 -2
View File
@@ -1,3 +1,3 @@
#!/bin/bash
echo "Starting WhatsApp Bridge 1.3.X..."
node /app/server.js
echo "Starting WhatsApp Bridge 1.4.0..."
exec node /app/server.js
+285 -108
View File
@@ -4,176 +4,353 @@ 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 lastQr = "";
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;
const SESSION_DATA = '/data/auth';
// --- AUTOMATISCHE SELBSTREINIGUNG BEIM START ---
// Löscht alte Chromium-Sperrdateien, die das "LADE CLIENT..."-Hängen verursachen
try {
const lockFolder = path.join(SESSION_DATA, 'session-ha_bridge_session');
const lockFile = path.join(lockFolder, 'SingletonLock');
if (fs.existsSync(lockFile)) {
fs.removeSync(lockFile);
console.log('🔒 Blockierendes Chromium-SingletonLock wurde automatisch entfernt!');
}
} catch (e) {
console.log('Info: Keine blockierende Sperrdatei beim Start gefunden.');
}
async function handleCrash(errorContext) {
console.error(`💥 CRITICAL ERROR CAUGHT: ${errorContext}`);
isReady = false;
function errorMessage(error) {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
try {
console.log('Versuche Browser sauber zu schließen...');
if (client && client.pupBrowser) {
await client.pupBrowser.close().catch(() => {});
return JSON.stringify(error);
} catch (_error) {
return String(error);
}
} catch (e) {
console.error('Browser-Close fehlgeschlagen:', e);
}
console.log('Add-on wird beendet. HA Watchdog startet es neu...');
process.exit(1);
}
process.on('uncaughtException', async (err) => {
if (err.message.includes('ProtocolError') || err.message.includes('Runtime.callFunctionOn') || err.message.includes('Target closed')) {
await handleCrash('Puppeteer Protocol Timeout / Crash');
} else {
console.error('Uncaught Exception:', err);
}
});
function findChromium() {
const candidates = [
process.env.CHROME_BIN,
'/usr/bin/chromium-browser',
'/usr/bin/chromium'
].filter(Boolean);
process.on('unhandledRejection', async (reason, promise) => {
if (reason && reason.message && reason.message.includes('Session closed')) {
await handleCrash('Unhandled Puppeteer Session Rejection');
} else {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
}
});
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
}
const client = new Client({
// 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: "ha_bridge_session"
clientId: CLIENT_ID
}),
authTimeoutMs: 90000,
qrMaxRetries: 5,
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: '/usr/bin/chromium-browser',
protocolTimeout: 0,
executablePath: findChromium(),
protocolTimeout: 120_000,
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-extensions'
// '--single-process' und '--no-zygote' wurden entfernt, da sie in Alpine 3.19 Hangs verursachen können!
'--disable-extensions',
'--no-first-run',
'--no-default-browser-check'
]
}
});
});
}
client.on('qr', (qr) => {
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;
console.log('Neuer QR Code generiert.');
});
lastError = null;
console.log('Neuer QR Code generiert. Bitte im Add-on-Webinterface scannen.');
});
client.on('ready', () => {
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;
lastQr = "";
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);
});
client.on('disconnected', async (reason) => {
console.log(`WhatsApp Verbindung getrennt (Grund: ${reason}).`);
await handleCrash('Externer Disconnect');
process.on('unhandledRejection', (reason) => {
if (isBrowserError(reason)) {
scheduleReconnect(`Chromium-Fehler: ${errorMessage(reason)}`);
return;
}
console.error('Nicht behandelte Promise-Ablehnung:', reason);
});
// --- ROUTES ---
// 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;
app.get('/', (req, res) => {
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/status', async (req, res) => {
try {
let qrData = null;
if (lastQr) {
qrData = await qrcode.toDataURL(lastQr);
}
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
});
let clientState = 'DISCONNECTED';
if (isReady) {
clientState = await client.getState().catch(() => 'ERROR');
}
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: isReady,
hasQr: lastQr !== "",
isReady,
hasQr: Boolean(lastQr),
qrCode: qrData,
isInitializing: isInitializing,
clientState: clientState
isInitializing,
clientState,
reconnectAttempts,
lastError
});
} catch (e) {
res.status(500).json({ error: e.message });
} catch (error) {
res.status(500).json({ error: errorMessage(error) });
}
});
// NOTFALL-SICHER: Löscht die Session radikal, selbst wenn der Client eingefroren ist!
app.post('/auth/logout', async (req, res) => {
app.post('/auth/logout', async (_req, res) => {
try {
console.log("Logout erzwungen über Web-UI...");
if (client) {
await client.logout().catch(() => {});
await client.destroy().catch(() => {});
}
await fs.remove(SESSION_DATA).catch(() => {});
res.json({ success: true, message: "Session radikal gelöscht. Addon startet jetzt sauber neu..." });
setTimeout(() => process.exit(0), 1500);
} catch (err) {
res.status(500).json({ error: err.message });
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) });
}
});
// NOTFALL-RESET: Putzt das komplette Verzeichnis leer
app.delete('/system/reset', async (req, res) => {
app.delete('/system/reset', async (_req, res) => {
try {
console.log("Factory Reset erzwungen...");
if (client) await client.destroy().catch(() => {});
await fs.emptyDir('/data').catch(() => {});
res.json({ success: true, message: "Werkseinstellungen erzwungen. Alles gelöscht. Neustart..." });
setTimeout(() => process.exit(0), 1500);
} catch (err) {
res.status(500).json({ error: err.message });
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 (!isReady) return res.status(503).json({ error: 'Client nicht bereit' });
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);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
return res.json({ success: true });
} catch (error) {
return res.status(500).json({ error: errorMessage(error) });
}
});
console.log('Initialisiere WhatsApp Client...');
client.initialize().catch(async (err) => {
await handleCrash(`Initialisierungsfehler: ${err.message}`);
});
async function shutdown(exitCode = 0) {
if (isShuttingDown) return;
isShuttingDown = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
clearInterval(healthCheckTimer);
console.log('WhatsApp Bridge wird sauber beendet...');
app.listen(3000, () => console.log('WhatsApp Bridge Server läuft auf Port 3000'));
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));
+9 -1
View File
@@ -47,12 +47,15 @@
};
async function update() {
const statusEl = document.getElementById('status');
const msgEl = document.getElementById('msg');
try {
const response = await fetch(getBaseUrl() + 'api/status');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const statusEl = document.getElementById('status');
const qrSection = document.getElementById('qr-section');
msgEl.innerText = data.lastError || '';
if (data.isReady) {
statusEl.innerText = "VERBUNDEN ✅";
@@ -67,6 +70,10 @@
statusEl.innerText = "LADE CLIENT... ⏳";
statusEl.className = "status-badge loading";
qrSection.style.display = "none";
} else if (data.lastError) {
statusEl.innerText = `NEUVERBINDUNG (${data.reconnectAttempts}) ⏳`;
statusEl.className = "status-badge loading";
qrSection.style.display = "none";
} else {
statusEl.innerText = "INITIALISIERE...";
statusEl.className = "status-badge loading";
@@ -75,6 +82,7 @@
} catch (e) {
statusEl.innerText = "VERBINDUNG VERLOREN ❌";
statusEl.className = "status-badge disconnected";
msgEl.innerText = e.message;
}
}