Add WhatsApp connector v2 (Baileys, QR auth)

This commit is contained in:
2026-07-19 10:08:12 +01:00
parent 61248454e3
commit 4e428073df
6 changed files with 565 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
{
"id": "whatsapp",
"name": "WhatsApp",
"version": "2.0.0",
"type": "mcp_local",
"scope": "user",
"launch_command": "node index.js",
"transport": "stdio",
"requires": ["NODE"],
"tags": ["messaging", "mcp", "local", "whatsapp", "qr"],
"dependencies": [
"@whiskeysockets/baileys>=6.7.9",
"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."
],
"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": "Read and send WhatsApp messages, list chats and search contacts on the user\u2019s linked WhatsApp."
}],
"auth": {
"type": "qr"
},
"mcp_config": {
"command": "node",
"args": ["index.js"],
"transport": "stdio"
},
"homepage": "https://github.com/WhiskeySockets/Baileys",
"icon_small": "icon_sm.png",
"icon_large": "icon_lg.png"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+491
View File
@@ -0,0 +1,491 @@
#!/usr/bin/env node
'use strict';
/**
* WhatsApp MCP Server (JSON-RPC 2.0 over stdio) — Baileys edition.
*
* Runs INSIDE the user's per-user container (blueprint §6/§7). Unlike the old
* whatsapp-web.js server, this one uses `@whiskeysockets/baileys`: a pure-WebSocket
* WhatsApp multi-device client with **no browser** — so it fits the slim
* `skald-runtime` image (node, no Chromium) and needs no puppeteer self-healing.
*
* ── Interactive login contract (the generic §15 seam) ───────────────────────────
* A per-user connector that needs an interactive login exposes ONE standard tool,
* `login_status`, that Skald's login API calls directly (never the agent). It
* returns a small JSON object the login panel renders:
*
* { "state": "connecting" | "need_scan" | "ready" | "logged_out",
* "qr": "data:image/png;base64,…" // present only while state == need_scan
* "message": "human-readable line" }
*
* The panel polls it; when `state == "ready"` Skald flips the connector's
* `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.
*/
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`);
}
// ── Paths ──────────────────────────────────────────────────────────────────
// Everything hangs off __dirname (the connector dir inside the container home,
// `~/.skald/mcp/<name>/`), which is bind-mounted and therefore durable.
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`); }
// A silent logger: Baileys requires one, and anything it prints must never reach
// stdout (that channel is reserved for JSON-RPC framing).
const silentLogger = (() => {
const noop = () => {};
const l = { level: 'silent', trace: noop, debug: noop, info: noop, warn: noop, error: noop, fatal: noop };
l.child = () => l;
return l;
})();
// ── 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 starting = false;
// ── 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
// for "what's going on now", not a full archive.
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;
function pushMessage(jid, m) {
if (!jid) return;
let arr = messages.get(jid);
if (!arr) { arr = []; messages.set(jid, arr); }
arr.push(m);
if (arr.length > MAX_MSGS_PER_CHAT) arr.splice(0, arr.length - MAX_MSGS_PER_CHAT);
}
function contactName(jid) {
const c = contacts.get(jid);
if (c && c.name) return c.name;
const ch = chats.get(jid);
if (ch && ch.name) return ch.name;
return jid ? jid.split('@')[0] : 'unknown';
}
function textOf(msg) {
const m = msg.message;
if (!m) return '';
return (
m.conversation ||
m.extendedTextMessage?.text ||
m.imageMessage?.caption ||
m.videoMessage?.caption ||
m.documentMessage?.caption ||
(m.imageMessage ? '[image]' : '') ||
(m.videoMessage ? '[video]' : '') ||
(m.audioMessage ? '[audio]' : '') ||
(m.documentMessage ? '[document]' : '') ||
(m.stickerMessage ? '[sticker]' : '') ||
''
);
}
// ── WhatsApp socket lifecycle ──────────────────────────────────────────────────
async function startSock() {
if (starting) return;
starting = true;
try {
if (!makeWASocket) { state = 'connecting'; return; }
fs.mkdirSync(AUTH_DIR, { recursive: true });
const { state: authState, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
let version;
try { ({ version } = await fetchLatestBaileysVersion()); } catch (_) { /* baileys default */ }
sock = makeWASocket({
version,
auth: authState,
logger: silentLogger,
browser: ['Skald', 'Chrome', '1.0.0'],
syncFullHistory: false,
markOnlineOnConnect: false,
generateHighQualityLinkPreview: false,
});
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', (u) => {
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;
log('connection open — ready');
}
if (connection === 'close') {
const code = lastDisconnect?.error?.output?.statusCode;
if (code === DisconnectReason.loggedOut) {
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.
starting = false;
setTimeout(() => startSock(), 500);
} else {
state = 'connecting';
log(`connection closed (code ${code ?? '?'}) — reconnecting`);
starting = false;
setTimeout(() => startSock(), 1500);
}
}
});
// Initial history sync: chats, contacts and a batch of messages.
sock.ev.on('messaging-history.set', ({ chats: hc, contacts: hcs, messages: hm }) => {
for (const c of hc || []) {
chats.set(c.id, {
id: c.id,
name: c.name || c.subject || null,
unread: c.unreadCount || 0,
conversationTimestamp: Number(c.conversationTimestamp) || 0,
});
}
for (const c of hcs || []) {
contacts.set(c.id, { id: c.id, name: c.name || c.notify || c.verifiedName || null });
}
for (const m of hm || []) ingestMessage(m, false);
});
sock.ev.on('chats.upsert', (cs) => {
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) => {
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) => {
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 }) => {
for (const m of ms) ingestMessage(m, type === 'notify');
});
} catch (e) {
log(`startSock error: ${e.message}`);
state = 'connecting';
} finally {
starting = false;
}
}
function ingestMessage(m, live) {
try {
const jid = m.key?.remoteJid;
if (!jid || jid === 'status@broadcast') return;
const text = textOf(m);
pushMessage(jid, {
id: m.key?.id,
fromMe: !!m.key?.fromMe,
ts: Number(m.messageTimestamp) || 0,
text,
author: m.key?.participant || (m.key?.fromMe ? meJid : jid),
});
if (live && !chats.has(jid)) {
chats.set(jid, { id: jid, name: m.pushName || null, unread: 0, conversationTimestamp: Number(m.messageTimestamp) || 0 });
} else if (live) {
const ch = chats.get(jid);
ch.conversationTimestamp = Number(m.messageTimestamp) || ch.conversationTimestamp;
if (m.pushName && !ch.name) ch.name = m.pushName;
}
} catch (_) {}
}
// ── Helpers ────────────────────────────────────────────────────────────────────
// Turn a plain phone number or a chat id into a WhatsApp jid.
function toJid(chat_id, number) {
if (chat_id && chat_id.includes('@')) return chat_id;
const raw = (chat_id || number || '').replace(/[^0-9]/g, '');
if (!raw) return null;
return `${raw}@s.whatsapp.net`;
}
function requireReady() {
if (state !== 'ready') {
throw new Error(`WhatsApp is not connected (state: ${state}). ` +
(state === 'need_scan' || state === 'logged_out'
? 'Open the connector in Skald and scan the QR code to sign in.'
: 'It is still connecting — try again in a few seconds.'));
}
}
// ── Tools: interactive login (the §15 generic contract) ─────────────────────────
async function toolLoginStatus() {
let qrDataUrl = null;
if (state === 'need_scan' && curQr) {
try { qrDataUrl = await qrcode.toDataURL(curQr, { width: 320, margin: 2 }); } catch (_) {}
}
const message = {
connecting: 'Connecting to WhatsApp…',
need_scan: 'Scan this QR code: WhatsApp → Settings → Linked Devices → Link a Device.',
ready: 'WhatsApp is connected.',
logged_out: 'This device was unlinked. Scan the new QR code to sign in again.',
}[state] || state;
// Returned as a JSON string in a text content part; the login API parses it.
return JSON.stringify({ state, qr: qrDataUrl, message });
}
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}` : '');
}
async function toolLogout() {
try { if (sock) await sock.logout(); } catch (_) {}
try { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } catch (_) {}
chats.clear(); contacts.clear(); messages.clear();
curQr = null; state = 'connecting'; starting = false; meJid = null;
setTimeout(() => startSock(), 500);
return 'Logged out and cleared the session. A new QR code will be generated — open the connector in Skald and scan it.';
}
// ── Tools: messaging ────────────────────────────────────────────────────────────
async function toolListChats(args) {
requireReady();
const max = Math.min(Math.max(1, args.max_chats || 20), 50);
const list = [...chats.values()]
.sort((a, b) => (b.conversationTimestamp || 0) - (a.conversationTimestamp || 0))
.slice(0, max);
if (!list.length) return 'No chats known yet. History may still be syncing — try again in a few seconds.';
const lines = [`Recent WhatsApp chats (${list.length}):`];
for (const c of list) {
const kind = c.id.endsWith('@g.us') ? '[group]' : '[chat]';
const unread = c.unread ? ` (${c.unread} unread)` : '';
lines.push(`- ${c.name || contactName(c.id)} ${kind}${unread} | ID: ${c.id}`);
}
return lines.join('\n');
}
async function toolGetMessages(args) {
requireReady();
const jid = toJid(args.chat_id, args.number);
if (!jid) return 'Error: provide chat_id or number.';
const limit = Math.min(Math.max(1, args.limit || 20), 100);
const offset = Math.max(0, args.offset || 0);
const arr = (messages.get(jid) || []).slice().sort((a, b) => (a.ts || 0) - (b.ts || 0));
if (!arr.length) return `No messages buffered for ${contactName(jid)} (${jid}). Only messages seen since sign-in are available.`;
const end = arr.length - offset;
const slice = arr.slice(Math.max(0, end - limit), Math.max(0, end));
const lines = [`Messages with ${contactName(jid)} (${jid}):`];
for (const m of slice) {
const who = m.fromMe ? 'me' : (jid.endsWith('@g.us') ? contactName(m.author) : contactName(jid));
const when = m.ts ? new Date(m.ts * 1000).toISOString().replace('T', ' ').slice(0, 16) : '';
lines.push(`[${when}] ${who}: ${m.text}`);
}
return lines.join('\n');
}
async function toolSendMessage(args) {
requireReady();
const jid = toJid(args.chat_id, args.number);
if (!jid) return 'Error: provide chat_id or number.';
if (!args.message) return 'Error: message is required.';
await sock.sendMessage(jid, { text: String(args.message) });
return `Message sent to ${contactName(jid)} (${jid}).`;
}
async function toolSearchContacts(args) {
requireReady();
const q = String(args.query || '').toLowerCase();
if (!q) return 'Error: query is required.';
const max = Math.min(Math.max(1, args.max_results || 20), 50);
const seen = new Set();
const out = [];
for (const c of contacts.values()) {
if (out.length >= max) break;
const name = c.name || '';
if (name.toLowerCase().includes(q) || c.id.includes(q)) {
if (seen.has(c.id)) continue;
seen.add(c.id);
out.push(`- ${name || contactName(c.id)} | ID: ${c.id}`);
}
}
if (!out.length) return `No contacts found matching "${args.query}".`;
return [`Contacts matching "${args.query}" (${out.length}):`, ...out].join('\n');
}
// ── MCP tool definitions ────────────────────────────────────────────────────────
const TOOLS = [
{
name: 'login_status',
description: 'Interactive-login status for this connector (used by the Skald login panel). Returns a JSON object {state, qr, message}: state is connecting|need_scan|ready|logged_out; qr is a data-URL PNG present only while a scan is needed. Safe to poll.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'status',
description: 'WhatsApp connection status as a short human-readable report. Call this first when another WhatsApp tool fails.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'logout',
description: 'Log out of WhatsApp: end the session, clear the stored credentials, and generate a fresh QR code to link a (possibly different) phone. After calling, the user must scan the new QR in the Skald connector page.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'list_chats',
description: 'List recent WhatsApp chats (contacts and groups) with name, ID and unread count. Only chats seen since sign-in / history sync are known.',
inputSchema: {
type: 'object',
properties: { max_chats: { type: 'integer', description: 'Max chats to return (default 20, max 50).' } },
},
},
{
name: 'get_messages',
description: 'Get buffered messages from a chat. Identify it with EITHER chat_id (from list_chats) OR a phone number with country code for an individual contact. Only messages seen since sign-in are available (no deep history).',
inputSchema: {
type: 'object',
properties: {
chat_id: { type: 'string', description: 'Chat ID, e.g. "39XXXXXXXXXX@s.whatsapp.net" or "…@g.us".' },
number: { type: 'string', description: 'Alternative to chat_id: phone number with country code (e.g. "393331234567"). Ignored if chat_id is given.' },
limit: { type: 'integer', description: 'Number of messages (default 20, max 100).' },
offset: { type: 'integer', description: 'Skip this many of the most recent messages (default 0).' },
},
},
},
{
name: 'send_message',
description: 'Send a WhatsApp text message. Identify the recipient with EITHER chat_id (from list_chats, use for groups) OR a phone number with country code for an individual contact.',
inputSchema: {
type: 'object',
properties: {
chat_id: { type: 'string', description: 'Chat ID to send to (use for groups).' },
number: { type: 'string', description: 'Alternative to chat_id: phone number with country code. Ignored if chat_id is given.' },
message: { type: 'string', description: 'The text to send.' },
},
required: ['message'],
},
},
{
name: 'search_contacts',
description: 'Search known WhatsApp contacts by name or number. Use to find a contact ID to message.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Name or partial name/number (case-insensitive).' },
max_results: { type: 'integer', description: 'Max contacts to return (default 20, max 50).' },
},
required: ['query'],
},
},
];
// ── JSON-RPC framing ─────────────────────────────────────────────────────────
function okResponse(id, result) { return JSON.stringify({ jsonrpc: '2.0', id, result }); }
function textResult(id, text, isError = false) {
const result = { content: [{ type: 'text', text }] };
if (isError) result.isError = true;
return JSON.stringify({ jsonrpc: '2.0', id, result });
}
async function handleRequest(msg) {
const { method, id, params } = msg;
if (method === 'initialize') {
return okResponse(id, {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: 'whatsapp', version: '2.0.0' },
});
}
if (method === 'notifications/initialized') return null;
if (method === 'tools/list') return okResponse(id, { tools: TOOLS });
if (method === 'tools/call') {
const toolName = params?.name || '';
const toolArgs = params?.arguments || {};
let text;
try {
switch (toolName) {
case 'login_status': text = await toolLoginStatus(); break;
case 'status': text = await toolStatus(); break;
case 'logout': text = await toolLogout(); break;
case 'list_chats': text = await toolListChats(toolArgs); break;
case 'get_messages': text = await toolGetMessages(toolArgs); break;
case 'send_message': text = await toolSendMessage(toolArgs); break;
case 'search_contacts': text = await toolSearchContacts(toolArgs); break;
default:
return textResult(id, `Unknown tool: ${toolName}`, true);
}
} catch (e) {
log(`tool '${toolName}' error: ${e.message}`);
return textResult(id, `Error: ${e.message}`, true);
}
const isErr = typeof text === 'string' && text.startsWith('Error:');
return textResult(id, text, isErr);
}
return JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
}
// ── Main ─────────────────────────────────────────────────────────────────────
async function main() {
log('Starting WhatsApp MCP server (Baileys)');
fs.mkdirSync(MEDIA_DIR, { recursive: true });
startSock().catch((e) => log(`initial startSock failed: ${e.message}`));
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
rl.on('line', async (line) => {
line = line.trim();
if (!line) return;
let msg;
try { msg = JSON.parse(line); } catch (e) { log(`bad JSON on stdin: ${e.message}`); return; }
const resp = await handleRequest(msg);
if (resp !== null) process.stdout.write(resp + '\n');
});
rl.on('close', () => { log('stdin closed, shutting down'); process.exit(0); });
process.on('SIGTERM', () => { log('SIGTERM'); process.exit(0); });
process.on('SIGINT', () => { log('SIGINT'); process.exit(0); });
}
main().catch((e) => { log(`Fatal: ${e.message}`); process.exit(1); });
+14
View File
@@ -0,0 +1,14 @@
{
"name": "skald-whatsapp-mcp",
"version": "2.0.0",
"private": true,
"description": "WhatsApp MCP connector for Skald (Baileys, no browser).",
"main": "index.js",
"engines": {
"node": ">=18"
},
"dependencies": {
"@whiskeysockets/baileys": "^6.7.9",
"qrcode": "^1.5.4"
}
}