Add WhatsApp connector v2 (Baileys, QR auth)
This commit is contained in:
@@ -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 |
@@ -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); });
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user