whatsapp: fix history sync + session corruption, Baileys 7.0.0-rc14 (v7 / 2.1.0)

Diagnosed from the live server (two users, two accounts, one shared log).

- history sync was gated off entirely on Baileys 6.7.x: the library derives
  shouldSyncHistoryMessage from syncFullHistory when unset, and the connector
  shipped syncFullHistory: false. Now both are passed explicitly, so behaviour
  no longer depends on the resolved Baileys version. browser -> ['Mac OS', ...]
  because PLATFORM_MAP only grants a desktop-grade sync to Mac OS / Windows.
- reconnects leaked their socket, leaving two writers over auth/: no end(), no
  removeAllListeners(). ~15 reconnects/day produced 1422 Bad MAC lines, all on
  the account's own LID device 0. Added teardownSock(), per-socket generation
  guards, and a single-slot reconnect timer with exponential backoff.
- undecryptable messages (stubType CIPHERTEXT) were stored with empty text and
  rendered as blank transcript lines; now labelled and counted. Text-less
  protocol frames are dropped.
- fetchLatestBaileysVersion() ran on every reconnect; now cached for 6h.
- libsignal's direct console.error spam collapsed into one counted line, log
  lines tagged with the linked account, console.log redirected off stdout.

Baileys pinned ^6.7.9 -> 7.0.0-rc14 (the range resolved to 6.7.24 for one user
and 6.17.16 for the other). RC is ESM-only, so index.js is now ESM.

Verified on skald-runtime:v4 (Node 20.19.2): initialize, tools/list, status,
list_chats, version fetch, QR, and logout -> teardown -> single reconnect.

Remaining gap: the store is still in-memory only, so synced history is lost on
the next process restart.
This commit is contained in:
Daniele
2026-08-23 18:15:56 +01:00
parent 040a0b2320
commit a2bbedad40
6 changed files with 270 additions and 87 deletions
+219 -60
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env node
'use strict';
/**
* WhatsApp MCP Server (JSON-RPC 2.0 over stdio) — Baileys edition.
@@ -22,31 +21,26 @@
* `auth_state` to `ready`. WhatsApp's credential is the persisted session on disk
* (`./auth/`, under the bind-mounted home → survives a container recreate), not a
* token — so there is nothing to paste back, only a QR to scan.
*
* ── Why this file is ESM ────────────────────────────────────────────────────────
* Baileys 7.x ships ESM-only (`"type": "module"`, engines node >= 20). Importing it
* from CommonJS would rely on Node's `require(esm)` bridge, which is a moving target
* across Node versions — so the connector is ESM too, like the other node connectors.
*/
// Baileys uses the Web Crypto global (`crypto.subtle`), which Node only exposes as
// `globalThis.crypto` from v20+. The container ships Node 18 (Debian bookworm), so
// polyfill it from `node:crypto` — without this, the socket dies on connect with
// "crypto is not defined" and never reaches the QR.
const nodeCrypto = require('crypto');
import fs from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
import nodeCrypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import qrcode from 'qrcode';
// Baileys uses the Web Crypto global (`crypto.subtle`). Node exposes it as
// `globalThis.crypto` from v19+, but keep the polyfill so an older runtime still
// reaches the QR instead of dying on connect with "crypto is not defined".
if (!globalThis.crypto) globalThis.crypto = nodeCrypto.webcrypto;
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const qrcode = require('qrcode');
let makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, jidNormalizedUser;
try {
const baileys = require('@whiskeysockets/baileys');
makeWASocket = baileys.default || baileys.makeWASocket;
useMultiFileAuthState = baileys.useMultiFileAuthState;
DisconnectReason = baileys.DisconnectReason;
fetchLatestBaileysVersion = baileys.fetchLatestBaileysVersion;
jidNormalizedUser = baileys.jidNormalizedUser;
} catch (e) {
process.stderr.write(`[whatsapp_mcp] FATAL: baileys not installed (${e.message}). Run npm install.\n`);
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ── Paths ──────────────────────────────────────────────────────────────────
// Everything hangs off __dirname (the connector dir inside the container home,
@@ -54,7 +48,12 @@ try {
const AUTH_DIR = path.join(__dirname, 'auth'); // multi-file auth state (the "session")
const MEDIA_DIR = path.join(__dirname, 'media');
function log(msg) { process.stderr.write(`[whatsapp_mcp] ${msg}\n`); }
// ── Logging ────────────────────────────────────────────────────────────────────
// Skald funnels every user's whatsapp stderr into ONE shared log file, so each line
// carries the linked account once we know it — otherwise two users' failures are
// indistinguishable after the fact.
let logTag = 'whatsapp_mcp';
function log(msg) { process.stderr.write(`[${logTag}] ${msg}\n`); }
// A silent logger: Baileys requires one, and anything it prints must never reach
// stdout (that channel is reserved for JSON-RPC framing).
@@ -65,17 +64,67 @@ const silentLogger = (() => {
return l;
})();
// `libsignal` bypasses the Baileys logger and writes straight to the console: one
// "Failed to decrypt…" line plus a full stack trace *per candidate session* for every
// message it cannot open. That turned ~30 real failures into 1400+ log lines. Collapse
// the burst into a single counted line, and force any stray console.log to stderr —
// stdout belongs to the JSON-RPC framing and a dependency printing there corrupts it.
const rawConsoleError = console.error.bind(console);
let decryptFailures = 0;
console.error = (...args) => {
const first = typeof args[0] === 'string' ? args[0] : '';
if (first.startsWith('Session error:')) return; // per-session stack spam
if (first.startsWith('Failed to decrypt message with any known session')) {
decryptFailures++;
log(`could not decrypt an incoming message (${decryptFailures} since start)`);
return;
}
rawConsoleError(...args);
};
console.log = (...args) => console.error(...args);
console.info = console.warn = console.error;
// ── Baileys (loaded dynamically so a missing install degrades gracefully) ───────
let makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion,
jidNormalizedUser, WAMessageStubType;
async function loadBaileys() {
try {
const baileys = await import('@whiskeysockets/baileys');
makeWASocket = baileys.default || baileys.makeWASocket;
useMultiFileAuthState = baileys.useMultiFileAuthState;
DisconnectReason = baileys.DisconnectReason;
fetchLatestBaileysVersion = baileys.fetchLatestBaileysVersion;
jidNormalizedUser = baileys.jidNormalizedUser;
WAMessageStubType = baileys.WAMessageStubType;
return true;
} catch (e) {
log(`FATAL: baileys not installed (${e.message}). Run npm install.`);
return false;
}
}
// ── Connection state ─────────────────────────────────────────────────────────
// connecting socket starting or reconnecting
// need_scan a QR is available; the user must scan it
// ready authenticated and connected; tools operational
// logged_out the phone unlinked this device; a fresh QR + scan is required
let state = 'connecting';
let sock = null;
let curQr = null; // latest raw QR string (null once scanned / connected)
let meJid = null;
let state = 'connecting';
let sock = null;
let curQr = null; // latest raw QR string (null once scanned / connected)
let meJid = null;
let starting = false;
// Every socket gets a generation number. Events arriving from a superseded socket are
// dropped, so a late `close` from a dead socket can never schedule a second reconnect
// or stomp on the live one's state.
let generation = 0;
let reconnectTimer = null;
const RECONNECT_BASE_MS = 1500;
const RECONNECT_MAX_MS = 60_000;
let backoffMs = RECONNECT_BASE_MS;
// ── Lightweight in-memory store ───────────────────────────────────────────────
// Baileys keeps no chat/contact store of its own; we build a minimal one from the
// history-sync event and live upserts. It lives for the process lifetime — enough
@@ -120,38 +169,104 @@ function textOf(msg) {
);
}
// ── WhatsApp protocol version (cached) ─────────────────────────────────────────
// `fetchLatestBaileysVersion` is a network call. Doing it on every reconnect meant
// ~15 outbound requests a day, each able to hand us a protocol version the installed
// library cannot speak. Fetch once, reuse for a few hours, fall back to the last good
// value (or the library default) when the fetch fails.
const VERSION_TTL_MS = 6 * 60 * 60 * 1000;
let cachedVersion = null;
let cachedVersionAt = 0;
async function getWAVersion() {
const now = Date.now();
if (cachedVersion && now - cachedVersionAt < VERSION_TTL_MS) return cachedVersion;
try {
const { version } = await fetchLatestBaileysVersion();
cachedVersion = version;
cachedVersionAt = now;
log(`WhatsApp protocol version ${version.join('.')}`);
} catch (e) {
log(`version fetch failed (${e.message}) — using ${cachedVersion ? 'cached value' : 'library default'}`);
}
return cachedVersion || undefined;
}
// ── WhatsApp socket lifecycle ──────────────────────────────────────────────────
// Detach and close a socket for good. Baileys keeps its own keepalive and, more
// importantly, a `creds.update → saveCreds` handler bound to the auth-state snapshot
// it was built with. Leaving an old socket alive meant two writers over the same
// `auth/` directory on every reconnect — the way Signal sessions end up inconsistent
// and messages start failing with "Bad MAC".
function teardownSock(s) {
if (!s) return;
try { s.ev.removeAllListeners(); } catch { /* already gone */ }
try { s.ws?.removeAllListeners?.(); } catch { /* already gone */ }
try { s.end(undefined); } catch { /* already closed */ }
}
function scheduleReconnect(delayMs) {
if (reconnectTimer) return; // one pending reconnect at a time
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
startSock().catch((e) => log(`reconnect failed: ${e.message}`));
}, delayMs);
}
function disconnectName(code) {
const name = DisconnectReason?.[code];
return typeof name === 'string' ? name : 'unknown';
}
async function startSock() {
if (starting) return;
if (starting) { scheduleReconnect(2000); return; }
starting = true;
const myGen = ++generation;
try {
if (!makeWASocket) { state = 'connecting'; return; }
// Retire the previous socket BEFORE building a new one.
teardownSock(sock);
sock = null;
fs.mkdirSync(AUTH_DIR, { recursive: true });
const { state: authState, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
let version;
try { ({ version } = await fetchLatestBaileysVersion()); } catch (_) { /* baileys default */ }
const version = await getWAVersion();
sock = makeWASocket({
const s = makeWASocket({
version,
auth: authState,
logger: silentLogger,
browser: ['Skald', 'Chrome', '1.0.0'],
syncFullHistory: false,
// The browser identity is not cosmetic: `getWebInfo` only asks the phone for a
// desktop-grade history sync when browser[0] is 'Mac OS' or 'Windows'. With any
// other name the sub-platform stays WEB_BROWSER and `syncFullHistory` is inert.
// Trade-off: the phone lists this device as "Mac OS Chrome", not "Skald".
browser: ['Mac OS', 'Chrome', '121.0.0'],
syncFullHistory: true,
// Set explicitly rather than left to the default. Baileys 6.7.x derives this from
// `syncFullHistory` and would otherwise gate history processing off entirely;
// 6.17.x and 7.x default it to true. Pinning it here makes the behaviour the same
// whichever version is installed.
shouldSyncHistoryMessage: () => true,
markOnlineOnConnect: false,
generateHighQualityLinkPreview: false,
});
sock = s;
sock.ev.on('creds.update', saveCreds);
s.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', (u) => {
s.ev.on('connection.update', (u) => {
if (myGen !== generation) return; // event from a superseded socket
const { connection, lastDisconnect, qr } = u;
if (qr) { curQr = qr; state = 'need_scan'; log('QR ready — awaiting scan'); }
if (connection === 'open') {
curQr = null;
state = 'ready';
meJid = sock?.user?.id ? jidNormalizedUser(sock.user.id) : null;
backoffMs = RECONNECT_BASE_MS;
meJid = s.user?.id ? jidNormalizedUser(s.user.id) : null;
const who = s.user?.name || (meJid ? meJid.split('@')[0] : null);
if (who) logTag = `whatsapp_mcp ${who}`;
log('connection open — ready');
}
if (connection === 'close') {
@@ -160,21 +275,26 @@ async function startSock() {
state = 'logged_out';
curQr = null;
log('logged out by phone — clearing session');
try { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } catch (_) {}
// Re-init so a fresh QR is produced immediately.
teardownSock(s);
sock = null;
try { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } catch { /* nothing to clear */ }
starting = false;
setTimeout(() => startSock(), 500);
scheduleReconnect(500); // produce a fresh QR immediately
} else {
state = 'connecting';
log(`connection closed (code ${code ?? '?'}) — reconnecting`);
log(`connection closed (${code ?? '?'} ${disconnectName(code)}) — reconnecting in ${Math.round(backoffMs / 1000)}s`);
teardownSock(s);
sock = null;
starting = false;
setTimeout(() => startSock(), 1500);
scheduleReconnect(backoffMs);
backoffMs = Math.min(backoffMs * 2, RECONNECT_MAX_MS);
}
}
});
// Initial history sync: chats, contacts and a batch of messages.
sock.ev.on('messaging-history.set', ({ chats: hc, contacts: hcs, messages: hm }) => {
s.ev.on('messaging-history.set', ({ chats: hc, contacts: hcs, messages: hm, syncType, progress }) => {
if (myGen !== generation) return;
for (const c of hc || []) {
chats.set(c.id, {
id: c.id,
@@ -187,31 +307,40 @@ async function startSock() {
contacts.set(c.id, { id: c.id, name: c.name || c.notify || c.verifiedName || null });
}
for (const m of hm || []) ingestMessage(m, false);
log(`history sync (type ${syncType ?? '?'}${progress != null ? `, ${progress}%` : ''}): ` +
`+${(hc || []).length} chats, +${(hcs || []).length} contacts, +${(hm || []).length} messages ` +
`${chats.size} chats / ${contacts.size} contacts known`);
});
sock.ev.on('chats.upsert', (cs) => {
s.ev.on('chats.upsert', (cs) => {
if (myGen !== generation) return;
for (const c of cs) chats.set(c.id, {
id: c.id, name: c.name || c.subject || null,
unread: c.unreadCount || 0,
conversationTimestamp: Number(c.conversationTimestamp) || 0,
});
});
sock.ev.on('contacts.upsert', (cs) => {
s.ev.on('contacts.upsert', (cs) => {
if (myGen !== generation) return;
for (const c of cs) contacts.set(c.id, { id: c.id, name: c.name || c.notify || c.verifiedName || null });
});
sock.ev.on('contacts.update', (cs) => {
s.ev.on('contacts.update', (cs) => {
if (myGen !== generation) return;
for (const c of cs) {
const prev = contacts.get(c.id) || { id: c.id };
contacts.set(c.id, { ...prev, name: c.name || c.notify || prev.name || null });
}
});
sock.ev.on('messages.upsert', ({ messages: ms, type }) => {
s.ev.on('messages.upsert', ({ messages: ms, type }) => {
if (myGen !== generation) return;
for (const m of ms) ingestMessage(m, type === 'notify');
});
} catch (e) {
log(`startSock error: ${e.message}`);
state = 'connecting';
scheduleReconnect(backoffMs);
backoffMs = Math.min(backoffMs * 2, RECONNECT_MAX_MS);
} finally {
starting = false;
}
@@ -221,7 +350,19 @@ function ingestMessage(m, live) {
try {
const jid = m.key?.remoteJid;
if (!jid || jid === 'status@broadcast') return;
const text = textOf(m);
// A message Signal could not open arrives with no `message` payload and the
// CIPHERTEXT stub type. It used to be stored with an empty text, so `get_messages`
// rendered it as a blank line and the agent had no way to tell a silent gap from a
// genuinely empty message. Keep it, but say what it is.
const undecryptable = WAMessageStubType != null &&
m.messageStubType === WAMessageStubType.CIPHERTEXT;
const text = undecryptable ? '[undecryptable message]' : textOf(m);
// Protocol/system frames (reactions, receipts, key distribution…) carry no text
// and are pure noise in a transcript.
if (!text) return;
pushMessage(jid, {
id: m.key?.id,
fromMe: !!m.key?.fromMe,
@@ -236,7 +377,7 @@ function ingestMessage(m, live) {
ch.conversationTimestamp = Number(m.messageTimestamp) || ch.conversationTimestamp;
if (m.pushName && !ch.name) ch.name = m.pushName;
}
} catch (_) {}
} catch { /* one malformed frame must not stop the stream */ }
}
// ── Helpers ────────────────────────────────────────────────────────────────────
@@ -263,7 +404,7 @@ function requireReady() {
async function toolLoginStatus() {
let qrDataUrl = null;
if (state === 'need_scan' && curQr) {
try { qrDataUrl = await qrcode.toDataURL(curQr, { width: 320, margin: 2 }); } catch (_) {}
try { qrDataUrl = await qrcode.toDataURL(curQr, { width: 320, margin: 2 }); } catch { /* no QR to render */ }
}
const message = {
connecting: 'Connecting to WhatsApp…',
@@ -278,17 +419,25 @@ async function toolLoginStatus() {
async function toolStatus() {
const s = await toolLoginStatus();
const { state: st, message } = JSON.parse(s);
const chatCount = chats.size;
return `WhatsApp status: ${st.toUpperCase()}\n${message}` +
(st === 'ready' ? `\nKnown chats: ${chatCount}` : '');
(st === 'ready'
? `\nKnown chats: ${chats.size}` +
`\nKnown contacts: ${contacts.size}` +
(decryptFailures ? `\nUndecryptable messages since start: ${decryptFailures}` : '')
: '');
}
async function toolLogout() {
try { if (sock) await sock.logout(); } catch (_) {}
try { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } catch (_) {}
try { if (sock) await sock.logout(); } catch { /* already gone */ }
generation++; // orphan any in-flight socket events
teardownSock(sock);
sock = null;
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
try { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } catch { /* nothing to clear */ }
chats.clear(); contacts.clear(); messages.clear();
curQr = null; state = 'connecting'; starting = false; meJid = null;
setTimeout(() => startSock(), 500);
backoffMs = RECONNECT_BASE_MS;
scheduleReconnect(500);
return 'Logged out and cleared the session. A new QR code will be generated — open the connector in Skald and scan it.';
}
@@ -447,7 +596,7 @@ async function handleRequest(msg) {
return okResponse(id, {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: 'whatsapp', version: '2.0.0' },
serverInfo: { name: 'whatsapp', version: '2.1.0' },
});
}
if (method === 'notifications/initialized') return null;
@@ -485,7 +634,10 @@ async function handleRequest(msg) {
async function main() {
log('Starting WhatsApp MCP server (Baileys)');
fs.mkdirSync(MEDIA_DIR, { recursive: true });
startSock().catch((e) => log(`initial startSock failed: ${e.message}`));
if (await loadBaileys()) {
startSock().catch((e) => log(`initial startSock failed: ${e.message}`));
}
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
rl.on('line', async (line) => {
@@ -496,10 +648,17 @@ async function main() {
const resp = await handleRequest(msg);
if (resp !== null) process.stdout.write(resp + '\n');
});
rl.on('close', () => { log('stdin closed, shutting down'); process.exit(0); });
rl.on('close', () => { log('stdin closed, shutting down'); shutdown(0); });
process.on('SIGTERM', () => { log('SIGTERM'); process.exit(0); });
process.on('SIGINT', () => { log('SIGINT'); process.exit(0); });
process.on('SIGTERM', () => { log('SIGTERM'); shutdown(0); });
process.on('SIGINT', () => { log('SIGINT'); shutdown(0); });
}
function shutdown(code) {
generation++;
teardownSock(sock);
sock = null;
process.exit(code);
}
main().catch((e) => { log(`Fatal: ${e.message}`); process.exit(1); });