diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d05e75..394607a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 ### Fixed diff --git a/connectors/connectors.json b/connectors/connectors.json index af56168..ef54c9f 100644 --- a/connectors/connectors.json +++ b/connectors/connectors.json @@ -794,14 +794,14 @@ "type": "qr" }, "folder": "whatsapp", - "version": 6, - "version_string": "2.0.6", - "version_release_date": "2026-07-21", + "version": 7, + "version_string": "2.1.0", + "version_release_date": "2026-08-23", "files": [ { "path": "connector.json", - "sha256": "6ccae6fdf863bb6bd531d9ee0c4dcc65d46c18dd5a5f01ec914be7788f05aa31", - "size": 1354 + "sha256": "87bac98a524c604ca016a80b26200ae16aaee3926baecfe99e1c336833823307", + "size": 1485 }, { "path": "icon_lg.png", @@ -815,13 +815,13 @@ }, { "path": "index.js", - "sha256": "0bfaa3c5449f22a4e2c4c99c6e9402bb24651647eca9a36dbd7a639c95a6bfd6", - "size": 21846 + "sha256": "fa1f1c31c1f3b6b32df3de0a1175e1cb51a0417dce7e3aa52fa8655900dd2967", + "size": 29495 }, { "path": "package.json", - "sha256": "b99a9dccfd286b59e8482076129f2e40f7b339eba6c3db2a744887d65bd4964e", - "size": 302 + "sha256": "d4c6c01230729388f418b3a2b05d5a0dedeb4af78ebde83f0669117dd71db721", + "size": 325 } ] }, diff --git a/connectors/whatsapp/connector.json b/connectors/whatsapp/connector.json index debdbb7..8a057ac 100644 --- a/connectors/whatsapp/connector.json +++ b/connectors/whatsapp/connector.json @@ -16,19 +16,20 @@ "qr" ], "dependencies": [ - "@whiskeysockets/baileys>=6.7.9", - "qrcode>=1.5.4" + "@whiskeysockets/baileys@7.0.0-rc14", + "qrcode@1.5.4" ], "setup_instructions": [ "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 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": [ { "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.", - "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." + "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 — read and send messages, manage chats, search contacts and messages, and download media from the user's linked WhatsApp session. QR-based authentication." } ], "auth": { @@ -44,7 +45,7 @@ "homepage": "https://github.com/WhiskeySockets/Baileys", "icon_small": "icon_sm.png", "icon_large": "icon_lg.png", - "version": 3, - "version_string": "2.0.3", - "version_release_date": "2026-07-21" -} \ No newline at end of file + "version": 7, + "version_string": "2.1.0", + "version_release_date": "2026-08-23" +} diff --git a/connectors/whatsapp/fragment.json b/connectors/whatsapp/fragment.json index 7ac4fe0..e741d46 100644 --- a/connectors/whatsapp/fragment.json +++ b/connectors/whatsapp/fragment.json @@ -5,7 +5,7 @@ "scope": "user", "icon_small": "whatsapp/icon_sm.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": [ "NODE" ], @@ -20,7 +20,7 @@ "type": "qr" }, "folder": "whatsapp", - "version": 6, - "version_string": "2.0.6", - "version_release_date": "2026-07-21" -} \ No newline at end of file + "version": 7, + "version_string": "2.1.0", + "version_release_date": "2026-08-23" +} diff --git a/connectors/whatsapp/index.js b/connectors/whatsapp/index.js index 08b703a..eb99fa1 100644 --- a/connectors/whatsapp/index.js +++ b/connectors/whatsapp/index.js @@ -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); }); diff --git a/connectors/whatsapp/package.json b/connectors/whatsapp/package.json index 51fe3c3..7de66c8 100644 --- a/connectors/whatsapp/package.json +++ b/connectors/whatsapp/package.json @@ -1,14 +1,15 @@ { "name": "skald-whatsapp-mcp", - "version": "2.0.0", + "version": "2.1.0", "private": true, + "type": "module", "description": "WhatsApp MCP connector for Skald (Baileys, no browser).", "main": "index.js", "engines": { - "node": ">=18" + "node": ">=20" }, "dependencies": { - "@whiskeysockets/baileys": "^6.7.9", - "qrcode": "^1.5.4" + "@whiskeysockets/baileys": "7.0.0-rc14", + "qrcode": "1.5.4" } }