whatsapp: persist chats and messages across restarts (v8 / 2.2.0)

WhatsApp delivers a history sync at login, not on reconnect, so an in-memory
store left the connector blind after every process restart (23 in two weeks).
The store is now mirrored to store/, next to auth/ and bind-mounted the same
way.

Not SQLite: node:sqlite needs Node 22 (unflagged only from 24) and the runtime
image ships Debian trixie's nodejs = 20.19.2; better-sqlite3 is native and the
slim image has no toolchain. So an append-only JSONL log for messages plus a
debounced JSON snapshot for chats/contacts, both written tmp+rename so a crash
cannot truncate them. Compaction on load and every 500 appends keeps the log
from creeping upward.

Also fixes a pre-existing duplication bug: pushMessage appended unconditionally,
re-adding every message a history re-sync redelivered. It now returns false on a
known id and the message is skipped in both the transcript and the log.

MAX_MSGS_PER_CHAT 200 -> 500. logout deletes store/ with auth/, so re-linking a
different phone cannot inherit the previous account's history.

Data at rest: message text is now written to the user's bind-mounted home.
Nothing but session keys was persisted before.

Verified on skald-runtime:v4 with a seeded store: 751 lines with 50 duplicates,
a 700-message chat and a torn trailing line -> 701 loaded, compacted to 501, cap
applied, second run 501 -> 501 unchanged.
This commit is contained in:
Daniele
2026-08-23 18:22:50 +01:00
parent a2bbedad40
commit 0c04ba2e4c
5 changed files with 145 additions and 18 deletions
+4 -3
View File
@@ -23,7 +23,8 @@
"Skald installs dependencies automatically (npm install).",
"Open the connector in Skald and scan the QR code with your WhatsApp phone.",
"The session is persisted and survives restarts.",
"The phone lists this device as \"Mac OS Chrome\": that browser identity is what makes WhatsApp deliver the full history sync."
"The phone lists this device as \"Mac OS Chrome\": that browser identity is what makes WhatsApp deliver the full history sync.",
"Chats and messages are mirrored to ./store/ so they survive a restart; `logout` deletes them along with the session."
],
"docs": [
{
@@ -45,7 +46,7 @@
"homepage": "https://github.com/WhiskeySockets/Baileys",
"icon_small": "icon_sm.png",
"icon_large": "icon_lg.png",
"version": 7,
"version_string": "2.1.0",
"version": 8,
"version_string": "2.2.0",
"version_release_date": "2026-08-23"
}
+2 -2
View File
@@ -20,7 +20,7 @@
"type": "qr"
},
"folder": "whatsapp",
"version": 7,
"version_string": "2.1.0",
"version": 8,
"version_string": "2.2.0",
"version_release_date": "2026-08-23"
}
+123 -7
View File
@@ -125,22 +125,126 @@ const RECONNECT_BASE_MS = 1500;
const RECONNECT_MAX_MS = 60_000;
let backoffMs = RECONNECT_BASE_MS;
// ── Lightweight in-memory store ───────────────────────────────────────────────
// ── 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
// for "what's going on now", not a full archive.
// history-sync event and live upserts. It is mirrored to `store/` (bind-mounted next
// to `auth/`, so it survives a container recreate) because WhatsApp only delivers a
// history sync at *login* — without a mirror every process restart left the connector
// blind until new messages happened to arrive.
//
// Deliberately not SQLite: `node:sqlite` needs Node 22 (the runtime image is 20.19.2)
// and `better-sqlite3` is a native module the slim image cannot build. So: an
// append-only JSONL log for messages (O(1) per message) plus a small debounced
// snapshot for chats/contacts.
const chats = new Map(); // jid -> { id, name, unread, conversationTimestamp }
const contacts = new Map(); // jid -> { id, name }
const messages = new Map(); // jid -> [ { id, fromMe, ts, text, author } ] (capped)
const MAX_MSGS_PER_CHAT = 200;
const MAX_MSGS_PER_CHAT = 500;
const STORE_DIR = path.join(__dirname, 'store');
const MSG_LOG = path.join(STORE_DIR, 'messages.jsonl');
const META_FILE = path.join(STORE_DIR, 'meta.json');
const COMPACT_EVERY = 500; // appends between rewrites of the log
const META_DEBOUNCE_MS = 5000;
let appendsSinceCompact = 0;
let metaTimer = null;
// Returns false when the message was already known: history sync re-delivers the same
// ids on every login, and without this both the log and the transcript would grow a
// duplicate per restart.
function pushMessage(jid, m) {
if (!jid) return;
if (!jid) return false;
let arr = messages.get(jid);
if (!arr) { arr = []; messages.set(jid, arr); }
if (m.id && arr.some((x) => x.id === m.id)) return false;
arr.push(m);
if (arr.length > MAX_MSGS_PER_CHAT) arr.splice(0, arr.length - MAX_MSGS_PER_CHAT);
return true;
}
function writeFileAtomic(file, data) {
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, data);
fs.renameSync(tmp, file); // rename is atomic: a crash mid-write cannot truncate the store
}
function loadStore() {
try {
const meta = JSON.parse(fs.readFileSync(META_FILE, 'utf8'));
for (const c of meta.chats || []) if (c?.id) chats.set(c.id, c);
for (const c of meta.contacts || []) if (c?.id) contacts.set(c.id, c);
} catch { /* first run, or a snapshot we cannot parse — start empty */ }
let restored = 0;
try {
for (const line of fs.readFileSync(MSG_LOG, 'utf8').split('\n')) {
if (!line) continue;
try {
const { jid, m } = JSON.parse(line);
if (pushMessage(jid, m)) restored++;
} catch { /* skip a torn trailing line */ }
}
} catch { /* no log yet */ }
if (restored || chats.size || contacts.size) {
log(`store loaded: ${chats.size} chats, ${contacts.size} contacts, ${restored} messages`);
}
// Rewrite immediately: replays the per-chat cap and drops duplicates, so the log
// cannot creep upward across restarts.
if (restored) compactMessageLog();
}
function compactMessageLog() {
try {
fs.mkdirSync(STORE_DIR, { recursive: true });
const out = [];
for (const [jid, arr] of messages) for (const m of arr) out.push(JSON.stringify({ jid, m }));
writeFileAtomic(MSG_LOG, out.length ? out.join('\n') + '\n' : '');
appendsSinceCompact = 0;
} catch (e) {
log(`store compaction failed: ${e.message}`);
}
}
function appendMessage(jid, m) {
try {
fs.mkdirSync(STORE_DIR, { recursive: true });
fs.appendFileSync(MSG_LOG, JSON.stringify({ jid, m }) + '\n');
if (++appendsSinceCompact >= COMPACT_EVERY) compactMessageLog();
} catch (e) {
log(`store append failed: ${e.message}`);
}
}
function saveMeta() {
try {
fs.mkdirSync(STORE_DIR, { recursive: true });
writeFileAtomic(META_FILE, JSON.stringify({
chats: [...chats.values()],
contacts: [...contacts.values()],
}));
} catch (e) {
log(`store meta save failed: ${e.message}`);
}
}
// chats/contacts churn in bursts during a history sync; one write per burst is enough.
function scheduleMetaSave() {
if (metaTimer) return;
metaTimer = setTimeout(() => { metaTimer = null; saveMeta(); }, META_DEBOUNCE_MS);
}
function flushStore() {
if (metaTimer) { clearTimeout(metaTimer); metaTimer = null; }
saveMeta();
}
function clearStore() {
try { fs.rmSync(STORE_DIR, { recursive: true, force: true }); } catch { /* nothing to clear */ }
appendsSinceCompact = 0;
if (metaTimer) { clearTimeout(metaTimer); metaTimer = null; }
}
function contactName(jid) {
@@ -307,6 +411,7 @@ 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);
scheduleMetaSave();
log(`history sync (type ${syncType ?? '?'}${progress != null ? `, ${progress}%` : ''}): ` +
`+${(hc || []).length} chats, +${(hcs || []).length} contacts, +${(hm || []).length} messages ` +
`${chats.size} chats / ${contacts.size} contacts known`);
@@ -319,10 +424,12 @@ async function startSock() {
unread: c.unreadCount || 0,
conversationTimestamp: Number(c.conversationTimestamp) || 0,
});
scheduleMetaSave();
});
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 });
scheduleMetaSave();
});
s.ev.on('contacts.update', (cs) => {
if (myGen !== generation) return;
@@ -330,6 +437,7 @@ async function startSock() {
const prev = contacts.get(c.id) || { id: c.id };
contacts.set(c.id, { ...prev, name: c.name || c.notify || prev.name || null });
}
scheduleMetaSave();
});
s.ev.on('messages.upsert', ({ messages: ms, type }) => {
@@ -363,19 +471,24 @@ function ingestMessage(m, live) {
// and are pure noise in a transcript.
if (!text) return;
pushMessage(jid, {
const stored = {
id: m.key?.id,
fromMe: !!m.key?.fromMe,
ts: Number(m.messageTimestamp) || 0,
text,
author: m.key?.participant || (m.key?.fromMe ? meJid : jid),
});
};
if (!pushMessage(jid, stored)) return; // already known — do not log it twice
appendMessage(jid, stored);
if (live && !chats.has(jid)) {
chats.set(jid, { id: jid, name: m.pushName || null, unread: 0, conversationTimestamp: Number(m.messageTimestamp) || 0 });
scheduleMetaSave();
} else if (live) {
const ch = chats.get(jid);
ch.conversationTimestamp = Number(m.messageTimestamp) || ch.conversationTimestamp;
if (m.pushName && !ch.name) ch.name = m.pushName;
scheduleMetaSave();
}
} catch { /* one malformed frame must not stop the stream */ }
}
@@ -435,6 +548,7 @@ async function toolLogout() {
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();
clearStore();
curQr = null; state = 'connecting'; starting = false; meJid = null;
backoffMs = RECONNECT_BASE_MS;
scheduleReconnect(500);
@@ -634,6 +748,7 @@ async function handleRequest(msg) {
async function main() {
log('Starting WhatsApp MCP server (Baileys)');
fs.mkdirSync(MEDIA_DIR, { recursive: true });
loadStore();
if (await loadBaileys()) {
startSock().catch((e) => log(`initial startSock failed: ${e.message}`));
@@ -658,6 +773,7 @@ function shutdown(code) {
generation++;
teardownSock(sock);
sock = null;
flushStore();
process.exit(code);
}