|
|
|
@@ -125,22 +125,126 @@ const RECONNECT_BASE_MS = 1500;
|
|
|
|
|
const RECONNECT_MAX_MS = 60_000;
|
|
|
|
|
let backoffMs = RECONNECT_BASE_MS;
|
|
|
|
|
|
|
|
|
|
// ── Lightweight in-memory store ───────────────────────────────────────────────
|
|
|
|
|
// ── 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.
|
|
|
|
|
// history-sync event and live upserts. It is mirrored to `store/` (bind-mounted next
|
|
|
|
|
// to `auth/`, so it survives a container recreate) because WhatsApp only delivers a
|
|
|
|
|
// history sync at *login* — without a mirror every process restart left the connector
|
|
|
|
|
// blind until new messages happened to arrive.
|
|
|
|
|
//
|
|
|
|
|
// Deliberately not SQLite: `node:sqlite` needs Node 22 (the runtime image is 20.19.2)
|
|
|
|
|
// and `better-sqlite3` is a native module the slim image cannot build. So: an
|
|
|
|
|
// append-only JSONL log for messages (O(1) per message) plus a small debounced
|
|
|
|
|
// snapshot for chats/contacts.
|
|
|
|
|
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;
|
|
|
|
|
const MAX_MSGS_PER_CHAT = 500;
|
|
|
|
|
|
|
|
|
|
const STORE_DIR = path.join(__dirname, 'store');
|
|
|
|
|
const MSG_LOG = path.join(STORE_DIR, 'messages.jsonl');
|
|
|
|
|
const META_FILE = path.join(STORE_DIR, 'meta.json');
|
|
|
|
|
const COMPACT_EVERY = 500; // appends between rewrites of the log
|
|
|
|
|
const META_DEBOUNCE_MS = 5000;
|
|
|
|
|
|
|
|
|
|
let appendsSinceCompact = 0;
|
|
|
|
|
let metaTimer = null;
|
|
|
|
|
|
|
|
|
|
// Returns false when the message was already known: history sync re-delivers the same
|
|
|
|
|
// ids on every login, and without this both the log and the transcript would grow a
|
|
|
|
|
// duplicate per restart.
|
|
|
|
|
function pushMessage(jid, m) {
|
|
|
|
|
if (!jid) return;
|
|
|
|
|
if (!jid) return false;
|
|
|
|
|
let arr = messages.get(jid);
|
|
|
|
|
if (!arr) { arr = []; messages.set(jid, arr); }
|
|
|
|
|
if (m.id && arr.some((x) => x.id === m.id)) return false;
|
|
|
|
|
arr.push(m);
|
|
|
|
|
if (arr.length > MAX_MSGS_PER_CHAT) arr.splice(0, arr.length - MAX_MSGS_PER_CHAT);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeFileAtomic(file, data) {
|
|
|
|
|
const tmp = `${file}.tmp`;
|
|
|
|
|
fs.writeFileSync(tmp, data);
|
|
|
|
|
fs.renameSync(tmp, file); // rename is atomic: a crash mid-write cannot truncate the store
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadStore() {
|
|
|
|
|
try {
|
|
|
|
|
const meta = JSON.parse(fs.readFileSync(META_FILE, 'utf8'));
|
|
|
|
|
for (const c of meta.chats || []) if (c?.id) chats.set(c.id, c);
|
|
|
|
|
for (const c of meta.contacts || []) if (c?.id) contacts.set(c.id, c);
|
|
|
|
|
} catch { /* first run, or a snapshot we cannot parse — start empty */ }
|
|
|
|
|
|
|
|
|
|
let restored = 0;
|
|
|
|
|
try {
|
|
|
|
|
for (const line of fs.readFileSync(MSG_LOG, 'utf8').split('\n')) {
|
|
|
|
|
if (!line) continue;
|
|
|
|
|
try {
|
|
|
|
|
const { jid, m } = JSON.parse(line);
|
|
|
|
|
if (pushMessage(jid, m)) restored++;
|
|
|
|
|
} catch { /* skip a torn trailing line */ }
|
|
|
|
|
}
|
|
|
|
|
} catch { /* no log yet */ }
|
|
|
|
|
|
|
|
|
|
if (restored || chats.size || contacts.size) {
|
|
|
|
|
log(`store loaded: ${chats.size} chats, ${contacts.size} contacts, ${restored} messages`);
|
|
|
|
|
}
|
|
|
|
|
// Rewrite immediately: replays the per-chat cap and drops duplicates, so the log
|
|
|
|
|
// cannot creep upward across restarts.
|
|
|
|
|
if (restored) compactMessageLog();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compactMessageLog() {
|
|
|
|
|
try {
|
|
|
|
|
fs.mkdirSync(STORE_DIR, { recursive: true });
|
|
|
|
|
const out = [];
|
|
|
|
|
for (const [jid, arr] of messages) for (const m of arr) out.push(JSON.stringify({ jid, m }));
|
|
|
|
|
writeFileAtomic(MSG_LOG, out.length ? out.join('\n') + '\n' : '');
|
|
|
|
|
appendsSinceCompact = 0;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log(`store compaction failed: ${e.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendMessage(jid, m) {
|
|
|
|
|
try {
|
|
|
|
|
fs.mkdirSync(STORE_DIR, { recursive: true });
|
|
|
|
|
fs.appendFileSync(MSG_LOG, JSON.stringify({ jid, m }) + '\n');
|
|
|
|
|
if (++appendsSinceCompact >= COMPACT_EVERY) compactMessageLog();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log(`store append failed: ${e.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveMeta() {
|
|
|
|
|
try {
|
|
|
|
|
fs.mkdirSync(STORE_DIR, { recursive: true });
|
|
|
|
|
writeFileAtomic(META_FILE, JSON.stringify({
|
|
|
|
|
chats: [...chats.values()],
|
|
|
|
|
contacts: [...contacts.values()],
|
|
|
|
|
}));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log(`store meta save failed: ${e.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// chats/contacts churn in bursts during a history sync; one write per burst is enough.
|
|
|
|
|
function scheduleMetaSave() {
|
|
|
|
|
if (metaTimer) return;
|
|
|
|
|
metaTimer = setTimeout(() => { metaTimer = null; saveMeta(); }, META_DEBOUNCE_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function flushStore() {
|
|
|
|
|
if (metaTimer) { clearTimeout(metaTimer); metaTimer = null; }
|
|
|
|
|
saveMeta();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearStore() {
|
|
|
|
|
try { fs.rmSync(STORE_DIR, { recursive: true, force: true }); } catch { /* nothing to clear */ }
|
|
|
|
|
appendsSinceCompact = 0;
|
|
|
|
|
if (metaTimer) { clearTimeout(metaTimer); metaTimer = null; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function contactName(jid) {
|
|
|
|
@@ -307,6 +411,7 @@ 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);
|
|
|
|
|
scheduleMetaSave();
|
|
|
|
|
log(`history sync (type ${syncType ?? '?'}${progress != null ? `, ${progress}%` : ''}): ` +
|
|
|
|
|
`+${(hc || []).length} chats, +${(hcs || []).length} contacts, +${(hm || []).length} messages ` +
|
|
|
|
|
`→ ${chats.size} chats / ${contacts.size} contacts known`);
|
|
|
|
@@ -319,10 +424,12 @@ async function startSock() {
|
|
|
|
|
unread: c.unreadCount || 0,
|
|
|
|
|
conversationTimestamp: Number(c.conversationTimestamp) || 0,
|
|
|
|
|
});
|
|
|
|
|
scheduleMetaSave();
|
|
|
|
|
});
|
|
|
|
|
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 });
|
|
|
|
|
scheduleMetaSave();
|
|
|
|
|
});
|
|
|
|
|
s.ev.on('contacts.update', (cs) => {
|
|
|
|
|
if (myGen !== generation) return;
|
|
|
|
@@ -330,6 +437,7 @@ async function startSock() {
|
|
|
|
|
const prev = contacts.get(c.id) || { id: c.id };
|
|
|
|
|
contacts.set(c.id, { ...prev, name: c.name || c.notify || prev.name || null });
|
|
|
|
|
}
|
|
|
|
|
scheduleMetaSave();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
s.ev.on('messages.upsert', ({ messages: ms, type }) => {
|
|
|
|
@@ -363,19 +471,24 @@ function ingestMessage(m, live) {
|
|
|
|
|
// and are pure noise in a transcript.
|
|
|
|
|
if (!text) return;
|
|
|
|
|
|
|
|
|
|
pushMessage(jid, {
|
|
|
|
|
const stored = {
|
|
|
|
|
id: m.key?.id,
|
|
|
|
|
fromMe: !!m.key?.fromMe,
|
|
|
|
|
ts: Number(m.messageTimestamp) || 0,
|
|
|
|
|
text,
|
|
|
|
|
author: m.key?.participant || (m.key?.fromMe ? meJid : jid),
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
if (!pushMessage(jid, stored)) return; // already known — do not log it twice
|
|
|
|
|
appendMessage(jid, stored);
|
|
|
|
|
|
|
|
|
|
if (live && !chats.has(jid)) {
|
|
|
|
|
chats.set(jid, { id: jid, name: m.pushName || null, unread: 0, conversationTimestamp: Number(m.messageTimestamp) || 0 });
|
|
|
|
|
scheduleMetaSave();
|
|
|
|
|
} else if (live) {
|
|
|
|
|
const ch = chats.get(jid);
|
|
|
|
|
ch.conversationTimestamp = Number(m.messageTimestamp) || ch.conversationTimestamp;
|
|
|
|
|
if (m.pushName && !ch.name) ch.name = m.pushName;
|
|
|
|
|
scheduleMetaSave();
|
|
|
|
|
}
|
|
|
|
|
} catch { /* one malformed frame must not stop the stream */ }
|
|
|
|
|
}
|
|
|
|
@@ -435,6 +548,7 @@ async function toolLogout() {
|
|
|
|
|
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();
|
|
|
|
|
clearStore();
|
|
|
|
|
curQr = null; state = 'connecting'; starting = false; meJid = null;
|
|
|
|
|
backoffMs = RECONNECT_BASE_MS;
|
|
|
|
|
scheduleReconnect(500);
|
|
|
|
@@ -634,6 +748,7 @@ async function handleRequest(msg) {
|
|
|
|
|
async function main() {
|
|
|
|
|
log('Starting WhatsApp MCP server (Baileys)');
|
|
|
|
|
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
|
|
|
|
loadStore();
|
|
|
|
|
|
|
|
|
|
if (await loadBaileys()) {
|
|
|
|
|
startSock().catch((e) => log(`initial startSock failed: ${e.message}`));
|
|
|
|
@@ -658,6 +773,7 @@ function shutdown(code) {
|
|
|
|
|
generation++;
|
|
|
|
|
teardownSock(sock);
|
|
|
|
|
sock = null;
|
|
|
|
|
flushStore();
|
|
|
|
|
process.exit(code);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|