diff --git a/connectors/connectors.json b/connectors/connectors.json index 9caa5cb..006be94 100644 --- a/connectors/connectors.json +++ b/connectors/connectors.json @@ -297,6 +297,30 @@ {"path": "icon_lg.svg", "sha256": "b1a16c5da95783d79e06388e5a18bc6c8291875335e8ac48665edeff5aeafca7", "size": 372} ] }, + { + "id": "whatsapp", + "name": "WhatsApp", + "type": "mcp_local", + "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.", + "requires": [ + "NODE" + ], + "tags": ["messaging", "mcp", "local", "whatsapp", "qr"], + "auth": { + "type": "qr" + }, + "folder": "whatsapp", + "files": [ + {"path": "connector.json", "sha256": "e6b20b9519e19f13297bc1361b342b74bedf234470dbb43d19b39622cd388a4c", "size": 1163}, + {"path": "index.js", "sha256": "38be3cc9a5e9ecf7193f917fc3b90e40aeca9753d98d69f27f8155f5f980b4c0", "size": 21258}, + {"path": "package.json", "sha256": "b99a9dccfd286b59e8482076129f2e40f7b339eba6c3db2a744887d65bd4964e", "size": 302}, + {"path": "icon_sm.png", "sha256": "1128f198b2a07041f2e0c8c330fc998d29c310ec3225f866073b9e921aa59b5f", "size": 74223}, + {"path": "icon_lg.png", "sha256": "1128f198b2a07041f2e0c8c330fc998d29c310ec3225f866073b9e921aa59b5f", "size": 74223} + ] + }, { "id": "wikipedia", "name": "Wikipedia", diff --git a/connectors/whatsapp/connector.json b/connectors/whatsapp/connector.json new file mode 100644 index 0000000..b463f65 --- /dev/null +++ b/connectors/whatsapp/connector.json @@ -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" +} diff --git a/connectors/whatsapp/icon_lg.png b/connectors/whatsapp/icon_lg.png new file mode 100644 index 0000000..6602111 Binary files /dev/null and b/connectors/whatsapp/icon_lg.png differ diff --git a/connectors/whatsapp/icon_sm.png b/connectors/whatsapp/icon_sm.png new file mode 100644 index 0000000..6602111 Binary files /dev/null and b/connectors/whatsapp/icon_sm.png differ diff --git a/connectors/whatsapp/index.js b/connectors/whatsapp/index.js new file mode 100644 index 0000000..430dfe0 --- /dev/null +++ b/connectors/whatsapp/index.js @@ -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//`), 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); }); diff --git a/connectors/whatsapp/package.json b/connectors/whatsapp/package.json new file mode 100644 index 0000000..51fe3c3 --- /dev/null +++ b/connectors/whatsapp/package.json @@ -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" + } +}