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:
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## 2026-08-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **whatsapp: history sync silently disabled, and Signal sessions corrupted by reconnects (v7 / 2.1.0)** — diagnosed from the live server, where two users (two separate accounts, separate `auth/` dirs) share one log file. Four distinct defects, plus a dependency upgrade.
|
||||||
|
- **The connector processed no history at all on Baileys 6.7.x.** `Socket/index.js` derives the history gate from the sync flag when the caller leaves it unset: `if (config.shouldSyncHistoryMessage === undefined) newConfig.shouldSyncHistoryMessage = () => !!newConfig.syncFullHistory;`. The connector passed neither, and shipped `syncFullHistory: false` — so the gate evaluated to `() => false`, `shouldProcessHistoryMsg` was permanently false, and the history-sync blob was discarded. `list_chats` / `get_messages` only ever saw messages arriving live after startup, which is what "no chats known yet" really meant. The derivation does not exist in 6.17.x or 7.x, where the default is `() => true` — so the *same connector* behaved differently per user depending on which version npm had resolved.
|
||||||
|
- Fix: `shouldSyncHistoryMessage: () => true` is now passed **explicitly**, making behaviour identical on every Baileys version, and `syncFullHistory: true`. The `browser` identity had to change too: `getWebInfo` only requests a desktop-grade sync when `browser[0]` is `'Mac OS'` or `'Windows'` (`PLATFORM_MAP` has exactly those two keys) — with the old `['Skald', 'Chrome', …]` the sub-platform stayed `WEB_BROWSER` and `syncFullHistory` would have been inert. Now `['Mac OS', 'Chrome', '121.0.0']`; the trade-off is that the phone lists the device as "Mac OS Chrome" rather than "Skald".
|
||||||
|
- **Every reconnect leaked its socket, giving `auth/` two concurrent writers.** The `connection === 'close'` branch only set `starting = false` and re-entered `startSock()`: no `end()`, no `removeAllListeners()`. Each pass called `useMultiFileAuthState(AUTH_DIR)` again, building a fresh key cache, while the previous socket stayed alive with its own `creds.update → saveCreds` handler bound to the *old* snapshot. At ~15 reconnects/day (106× 428 `connectionClosed`, 42× 500 `badSession`, 34× 503, 9× 405 over two weeks) that is the standard route to inconsistent Signal state — and the logs showed the symptom: 1422 `Bad MAC` lines and 51× `Closing open session in favor of incoming prekey bundle`. Every failing session address was the account's **own LID at device 0** (its own phone): the own-device sync stream, not a remote contact.
|
||||||
|
- Fix: `teardownSock()` detaches listeners and closes the socket before a new one is built, each socket carries a **generation number** so events from a superseded socket are dropped, and reconnects go through a single-slot timer with exponential backoff (1.5s → 60s, reset on `open`) instead of a fixed 1.5s re-entry. Disconnects are now logged by name (`428 connectionClosed`) rather than bare code.
|
||||||
|
- **Undecryptable messages were stored as blank lines.** A message Signal cannot open arrives with no `message` payload and `messageStubType = CIPHERTEXT (2)`; `textOf()` returned `''` and `ingestMessage` stored it anyway, so `get_messages` rendered `[timestamp] name: ` and the agent could not distinguish a silent gap from an empty message. They are now labelled `[undecryptable message]`, counted, and surfaced in `status`. Text-less protocol frames (reactions, receipts, key distribution) are dropped instead of padding transcripts with blanks.
|
||||||
|
- **`fetchLatestBaileysVersion()` ran on every reconnect** — ~15 outbound calls/day, each able to hand the socket a protocol version the installed library cannot speak (the plausible source of the 9× 405 closes). Now fetched once and cached for 6h, falling back to the last good value, then to the library default.
|
||||||
|
- **Log noise made the shared file unreadable.** `libsignal` bypasses the Baileys logger and calls `console.error` directly — one `Failed to decrypt…` line plus a full stack trace *per candidate session* — which is how ~30 real failures became 1422 lines. `console.error` is now wrapped to collapse the burst into one counted line, and every connector line carries the linked account (`[whatsapp_mcp Daniele]`) so the two users' entries are separable. `console.log`/`info`/`warn` are redirected to stderr as well: stdout is the JSON-RPC channel and a dependency printing there would corrupt the framing.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **whatsapp: Baileys `^6.7.9` → pinned `7.0.0-rc14`, connector converted to ESM (v7 / 2.1.0)** — the caret range was resolving to different versions per install: 6.7.24 for one user, **6.17.16** for the other (that version sorts above 6.7.24 by semver but sits outside the maintained 6.7.x line; npm's `legacy` dist-tag points at 6.7.24, `latest` at 7.0.0-rc14). The user on 6.17.16 logged 681 decrypt failures against the other's 30. Both `@whiskeysockets/baileys` and `qrcode` are now pinned exactly, matching the convention already used by playwright/http-fetch/firecrawl.
|
||||||
|
- 7.0.0-rc14 is a release candidate — there is no stable 7.x — chosen deliberately: its defaults already do the right thing (`syncFullHistory: true`, no derived history gate) and the API surface the connector uses is unchanged (`makeWASocket`, `useMultiFileAuthState`, `DisconnectReason`, `fetchLatestBaileysVersion`, `jidNormalizedUser`, plus `WAMessageStubType`).
|
||||||
|
- It ships **ESM-only** (`"type": "module"`, engines `node >= 20`), so `index.js` moved from CommonJS to ESM rather than leaning on Node's `require(esm)` bridge; `__dirname` is derived from `import.meta.url`. `package.json` gains `"type": "module"` and `engines.node >= 20` (the `skald-runtime:v4` image runs 20.19.2).
|
||||||
|
- Verified on the real runtime image, not locally: ESM import resolves under Node 20.19.2, `initialize` + `tools/list` (7 tools) + `status` + `list_chats` answer correctly, protocol version fetch and QR generation work, and the `logout` path exercises teardown → single reconnect → fresh QR with no duplicate socket ✅
|
||||||
|
- Re-aligned manifest↔fragment versions to `7` / `2.1.0` (they had drifted to 3/2.0.3 vs 6/2.0.6; skald reads `installed_version` from the manifest, so the update badge would not have appeared).
|
||||||
|
- ⚠️ Still outstanding: the chat/contact/message store is **in-memory only**. History sync arrives at login, not on every reconnect, so whatever the full sync delivers is lost at the next process restart. Persisting the store is the remaining piece and was left out of this change.
|
||||||
|
|
||||||
## 2026-08-20
|
## 2026-08-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -794,14 +794,14 @@
|
|||||||
"type": "qr"
|
"type": "qr"
|
||||||
},
|
},
|
||||||
"folder": "whatsapp",
|
"folder": "whatsapp",
|
||||||
"version": 6,
|
"version": 7,
|
||||||
"version_string": "2.0.6",
|
"version_string": "2.1.0",
|
||||||
"version_release_date": "2026-07-21",
|
"version_release_date": "2026-08-23",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "connector.json",
|
"path": "connector.json",
|
||||||
"sha256": "6ccae6fdf863bb6bd531d9ee0c4dcc65d46c18dd5a5f01ec914be7788f05aa31",
|
"sha256": "87bac98a524c604ca016a80b26200ae16aaee3926baecfe99e1c336833823307",
|
||||||
"size": 1354
|
"size": 1485
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "icon_lg.png",
|
"path": "icon_lg.png",
|
||||||
@@ -815,13 +815,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "index.js",
|
"path": "index.js",
|
||||||
"sha256": "0bfaa3c5449f22a4e2c4c99c6e9402bb24651647eca9a36dbd7a639c95a6bfd6",
|
"sha256": "fa1f1c31c1f3b6b32df3de0a1175e1cb51a0417dce7e3aa52fa8655900dd2967",
|
||||||
"size": 21846
|
"size": 29495
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "package.json",
|
"path": "package.json",
|
||||||
"sha256": "b99a9dccfd286b59e8482076129f2e40f7b339eba6c3db2a744887d65bd4964e",
|
"sha256": "d4c6c01230729388f418b3a2b05d5a0dedeb4af78ebde83f0669117dd71db721",
|
||||||
"size": 302
|
"size": 325
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,19 +16,20 @@
|
|||||||
"qr"
|
"qr"
|
||||||
],
|
],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@whiskeysockets/baileys>=6.7.9",
|
"@whiskeysockets/baileys@7.0.0-rc14",
|
||||||
"qrcode>=1.5.4"
|
"qrcode@1.5.4"
|
||||||
],
|
],
|
||||||
"setup_instructions": [
|
"setup_instructions": [
|
||||||
"Skald installs dependencies automatically (npm install).",
|
"Skald installs dependencies automatically (npm install).",
|
||||||
"Open the connector in Skald and scan the QR code with your WhatsApp phone.",
|
"Open the connector in Skald and scan the QR code with your WhatsApp phone.",
|
||||||
"The session is persisted and survives restarts."
|
"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."
|
||||||
],
|
],
|
||||||
"docs": [
|
"docs": [
|
||||||
{
|
{
|
||||||
"lang": "en",
|
"lang": "en",
|
||||||
"description": "Send and read WhatsApp messages from your own linked account. Uses Baileys multi-device protocol \u2014 no browser, no Chromium needed. Link via QR code.",
|
"description": "Send and read WhatsApp messages from your own linked account. Uses Baileys multi-device protocol — no browser, no Chromium needed. Link via QR code.",
|
||||||
"llm_short_description": "WhatsApp \u2014 read and send messages, manage chats, search contacts and messages, and download media from the user's linked WhatsApp session. QR-based authentication."
|
"llm_short_description": "WhatsApp — read and send messages, manage chats, search contacts and messages, and download media from the user's linked WhatsApp session. QR-based authentication."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
"homepage": "https://github.com/WhiskeySockets/Baileys",
|
"homepage": "https://github.com/WhiskeySockets/Baileys",
|
||||||
"icon_small": "icon_sm.png",
|
"icon_small": "icon_sm.png",
|
||||||
"icon_large": "icon_lg.png",
|
"icon_large": "icon_lg.png",
|
||||||
"version": 3,
|
"version": 7,
|
||||||
"version_string": "2.0.3",
|
"version_string": "2.1.0",
|
||||||
"version_release_date": "2026-07-21"
|
"version_release_date": "2026-08-23"
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"scope": "user",
|
"scope": "user",
|
||||||
"icon_small": "whatsapp/icon_sm.png",
|
"icon_small": "whatsapp/icon_sm.png",
|
||||||
"icon_large": "whatsapp/icon_lg.png",
|
"icon_large": "whatsapp/icon_lg.png",
|
||||||
"user_description": "Send and read WhatsApp messages from your own linked account. Link via QR code \u2014 no browser needed.",
|
"user_description": "Send and read WhatsApp messages from your own linked account. Link via QR code — no browser needed.",
|
||||||
"requires": [
|
"requires": [
|
||||||
"NODE"
|
"NODE"
|
||||||
],
|
],
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"type": "qr"
|
"type": "qr"
|
||||||
},
|
},
|
||||||
"folder": "whatsapp",
|
"folder": "whatsapp",
|
||||||
"version": 6,
|
"version": 7,
|
||||||
"version_string": "2.0.6",
|
"version_string": "2.1.0",
|
||||||
"version_release_date": "2026-07-21"
|
"version_release_date": "2026-08-23"
|
||||||
}
|
}
|
||||||
+219
-60
@@ -1,5 +1,4 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WhatsApp MCP Server (JSON-RPC 2.0 over stdio) — Baileys edition.
|
* 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_state` to `ready`. WhatsApp's credential is the persisted session on disk
|
||||||
* (`./auth/`, under the bind-mounted home → survives a container recreate), not a
|
* (`./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.
|
* 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
|
import fs from 'node:fs';
|
||||||
// `globalThis.crypto` from v20+. The container ships Node 18 (Debian bookworm), so
|
import path from 'node:path';
|
||||||
// polyfill it from `node:crypto` — without this, the socket dies on connect with
|
import readline from 'node:readline';
|
||||||
// "crypto is not defined" and never reaches the QR.
|
import nodeCrypto from 'node:crypto';
|
||||||
const nodeCrypto = require('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;
|
if (!globalThis.crypto) globalThis.crypto = nodeCrypto.webcrypto;
|
||||||
|
|
||||||
const fs = require('fs');
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Paths ──────────────────────────────────────────────────────────────────
|
// ── Paths ──────────────────────────────────────────────────────────────────
|
||||||
// Everything hangs off __dirname (the connector dir inside the container home,
|
// 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 AUTH_DIR = path.join(__dirname, 'auth'); // multi-file auth state (the "session")
|
||||||
const MEDIA_DIR = path.join(__dirname, 'media');
|
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
|
// A silent logger: Baileys requires one, and anything it prints must never reach
|
||||||
// stdout (that channel is reserved for JSON-RPC framing).
|
// stdout (that channel is reserved for JSON-RPC framing).
|
||||||
@@ -65,17 +64,67 @@ const silentLogger = (() => {
|
|||||||
return l;
|
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 ─────────────────────────────────────────────────────────
|
// ── Connection state ─────────────────────────────────────────────────────────
|
||||||
// connecting – socket starting or reconnecting
|
// connecting – socket starting or reconnecting
|
||||||
// need_scan – a QR is available; the user must scan it
|
// need_scan – a QR is available; the user must scan it
|
||||||
// ready – authenticated and connected; tools operational
|
// ready – authenticated and connected; tools operational
|
||||||
// logged_out – the phone unlinked this device; a fresh QR + scan is required
|
// logged_out – the phone unlinked this device; a fresh QR + scan is required
|
||||||
let state = 'connecting';
|
let state = 'connecting';
|
||||||
let sock = null;
|
let sock = null;
|
||||||
let curQr = null; // latest raw QR string (null once scanned / connected)
|
let curQr = null; // latest raw QR string (null once scanned / connected)
|
||||||
let meJid = null;
|
let meJid = null;
|
||||||
let starting = false;
|
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 ───────────────────────────────────────────────
|
// ── Lightweight in-memory store ───────────────────────────────────────────────
|
||||||
// Baileys keeps no chat/contact store of its own; we build a minimal one from the
|
// 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
|
// 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 ──────────────────────────────────────────────────
|
// ── 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() {
|
async function startSock() {
|
||||||
if (starting) return;
|
if (starting) { scheduleReconnect(2000); return; }
|
||||||
starting = true;
|
starting = true;
|
||||||
|
const myGen = ++generation;
|
||||||
try {
|
try {
|
||||||
if (!makeWASocket) { state = 'connecting'; return; }
|
if (!makeWASocket) { state = 'connecting'; return; }
|
||||||
|
|
||||||
|
// Retire the previous socket BEFORE building a new one.
|
||||||
|
teardownSock(sock);
|
||||||
|
sock = null;
|
||||||
|
|
||||||
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
||||||
|
|
||||||
const { state: authState, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
const { state: authState, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||||
let version;
|
const version = await getWAVersion();
|
||||||
try { ({ version } = await fetchLatestBaileysVersion()); } catch (_) { /* baileys default */ }
|
|
||||||
|
|
||||||
sock = makeWASocket({
|
const s = makeWASocket({
|
||||||
version,
|
version,
|
||||||
auth: authState,
|
auth: authState,
|
||||||
logger: silentLogger,
|
logger: silentLogger,
|
||||||
browser: ['Skald', 'Chrome', '1.0.0'],
|
// The browser identity is not cosmetic: `getWebInfo` only asks the phone for a
|
||||||
syncFullHistory: false,
|
// 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,
|
markOnlineOnConnect: false,
|
||||||
generateHighQualityLinkPreview: 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;
|
const { connection, lastDisconnect, qr } = u;
|
||||||
if (qr) { curQr = qr; state = 'need_scan'; log('QR ready — awaiting scan'); }
|
if (qr) { curQr = qr; state = 'need_scan'; log('QR ready — awaiting scan'); }
|
||||||
if (connection === 'open') {
|
if (connection === 'open') {
|
||||||
curQr = null;
|
curQr = null;
|
||||||
state = 'ready';
|
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');
|
log('connection open — ready');
|
||||||
}
|
}
|
||||||
if (connection === 'close') {
|
if (connection === 'close') {
|
||||||
@@ -160,21 +275,26 @@ async function startSock() {
|
|||||||
state = 'logged_out';
|
state = 'logged_out';
|
||||||
curQr = null;
|
curQr = null;
|
||||||
log('logged out by phone — clearing session');
|
log('logged out by phone — clearing session');
|
||||||
try { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } catch (_) {}
|
teardownSock(s);
|
||||||
// Re-init so a fresh QR is produced immediately.
|
sock = null;
|
||||||
|
try { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } catch { /* nothing to clear */ }
|
||||||
starting = false;
|
starting = false;
|
||||||
setTimeout(() => startSock(), 500);
|
scheduleReconnect(500); // produce a fresh QR immediately
|
||||||
} else {
|
} else {
|
||||||
state = 'connecting';
|
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;
|
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.
|
// 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 || []) {
|
for (const c of hc || []) {
|
||||||
chats.set(c.id, {
|
chats.set(c.id, {
|
||||||
id: 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 });
|
contacts.set(c.id, { id: c.id, name: c.name || c.notify || c.verifiedName || null });
|
||||||
}
|
}
|
||||||
for (const m of hm || []) ingestMessage(m, false);
|
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, {
|
for (const c of cs) chats.set(c.id, {
|
||||||
id: c.id, name: c.name || c.subject || null,
|
id: c.id, name: c.name || c.subject || null,
|
||||||
unread: c.unreadCount || 0,
|
unread: c.unreadCount || 0,
|
||||||
conversationTimestamp: Number(c.conversationTimestamp) || 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 });
|
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) {
|
for (const c of cs) {
|
||||||
const prev = contacts.get(c.id) || { id: c.id };
|
const prev = contacts.get(c.id) || { id: c.id };
|
||||||
contacts.set(c.id, { ...prev, name: c.name || c.notify || prev.name || null });
|
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');
|
for (const m of ms) ingestMessage(m, type === 'notify');
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`startSock error: ${e.message}`);
|
log(`startSock error: ${e.message}`);
|
||||||
state = 'connecting';
|
state = 'connecting';
|
||||||
|
scheduleReconnect(backoffMs);
|
||||||
|
backoffMs = Math.min(backoffMs * 2, RECONNECT_MAX_MS);
|
||||||
} finally {
|
} finally {
|
||||||
starting = false;
|
starting = false;
|
||||||
}
|
}
|
||||||
@@ -221,7 +350,19 @@ function ingestMessage(m, live) {
|
|||||||
try {
|
try {
|
||||||
const jid = m.key?.remoteJid;
|
const jid = m.key?.remoteJid;
|
||||||
if (!jid || jid === 'status@broadcast') return;
|
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, {
|
pushMessage(jid, {
|
||||||
id: m.key?.id,
|
id: m.key?.id,
|
||||||
fromMe: !!m.key?.fromMe,
|
fromMe: !!m.key?.fromMe,
|
||||||
@@ -236,7 +377,7 @@ function ingestMessage(m, live) {
|
|||||||
ch.conversationTimestamp = Number(m.messageTimestamp) || ch.conversationTimestamp;
|
ch.conversationTimestamp = Number(m.messageTimestamp) || ch.conversationTimestamp;
|
||||||
if (m.pushName && !ch.name) ch.name = m.pushName;
|
if (m.pushName && !ch.name) ch.name = m.pushName;
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch { /* one malformed frame must not stop the stream */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
@@ -263,7 +404,7 @@ function requireReady() {
|
|||||||
async function toolLoginStatus() {
|
async function toolLoginStatus() {
|
||||||
let qrDataUrl = null;
|
let qrDataUrl = null;
|
||||||
if (state === 'need_scan' && curQr) {
|
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 = {
|
const message = {
|
||||||
connecting: 'Connecting to WhatsApp…',
|
connecting: 'Connecting to WhatsApp…',
|
||||||
@@ -278,17 +419,25 @@ async function toolLoginStatus() {
|
|||||||
async function toolStatus() {
|
async function toolStatus() {
|
||||||
const s = await toolLoginStatus();
|
const s = await toolLoginStatus();
|
||||||
const { state: st, message } = JSON.parse(s);
|
const { state: st, message } = JSON.parse(s);
|
||||||
const chatCount = chats.size;
|
|
||||||
return `WhatsApp status: ${st.toUpperCase()}\n${message}` +
|
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() {
|
async function toolLogout() {
|
||||||
try { if (sock) await sock.logout(); } catch (_) {}
|
try { if (sock) await sock.logout(); } catch { /* already gone */ }
|
||||||
try { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } catch (_) {}
|
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();
|
chats.clear(); contacts.clear(); messages.clear();
|
||||||
curQr = null; state = 'connecting'; starting = false; meJid = null;
|
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.';
|
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, {
|
return okResponse(id, {
|
||||||
protocolVersion: '2024-11-05',
|
protocolVersion: '2024-11-05',
|
||||||
capabilities: { tools: {} },
|
capabilities: { tools: {} },
|
||||||
serverInfo: { name: 'whatsapp', version: '2.0.0' },
|
serverInfo: { name: 'whatsapp', version: '2.1.0' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (method === 'notifications/initialized') return null;
|
if (method === 'notifications/initialized') return null;
|
||||||
@@ -485,7 +634,10 @@ async function handleRequest(msg) {
|
|||||||
async function main() {
|
async function main() {
|
||||||
log('Starting WhatsApp MCP server (Baileys)');
|
log('Starting WhatsApp MCP server (Baileys)');
|
||||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
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 });
|
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||||
rl.on('line', async (line) => {
|
rl.on('line', async (line) => {
|
||||||
@@ -496,10 +648,17 @@ async function main() {
|
|||||||
const resp = await handleRequest(msg);
|
const resp = await handleRequest(msg);
|
||||||
if (resp !== null) process.stdout.write(resp + '\n');
|
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('SIGTERM', () => { log('SIGTERM'); shutdown(0); });
|
||||||
process.on('SIGINT', () => { log('SIGINT'); process.exit(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); });
|
main().catch((e) => { log(`Fatal: ${e.message}`); process.exit(1); });
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "skald-whatsapp-mcp",
|
"name": "skald-whatsapp-mcp",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"description": "WhatsApp MCP connector for Skald (Baileys, no browser).",
|
"description": "WhatsApp MCP connector for Skald (Baileys, no browser).",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@whiskeysockets/baileys": "^6.7.9",
|
"@whiskeysockets/baileys": "7.0.0-rc14",
|
||||||
"qrcode": "^1.5.4"
|
"qrcode": "1.5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user