/* * whatsapp-bridge — a real WhatsApp Web session that only ever reads. * * Long-lived sidecar to digest-engine. It keeps a logged-in web.whatsapp.com * session open and appends every incoming message to /data/messages.jsonl, which * digest-engine/ingest/whatsapp_ingest.py drains once per digest run. * * There is no send path in this file, and there must never be one: no reply(), * no sendMessage(), no sendSeen(), no chat.markUnread(). Per docs/project-plan.md * Phase 12 step 8, digest-engine has no mutation path anywhere by construction. * * READ digest-engine/README.md BEFORE ENABLING THIS. Automating a personal * WhatsApp account carries a real ban risk, mitigated here but not eliminated. */ 'use strict'; const fs = require('fs'); const path = require('path'); const qrcode = require('qrcode-terminal'); const { Client, LocalAuth } = require('whatsapp-web.js'); const DATA_DIR = process.env.WHATSAPP_DATA_DIR || '/data'; const MESSAGES_PATH = path.join(DATA_DIR, 'messages.jsonl'); const AUTH_PATH = path.join(DATA_DIR, '.wwebjs_auth'); fs.mkdirSync(DATA_DIR, { recursive: true }); const client = new Client({ authStrategy: new LocalAuth({ dataPath: AUTH_PATH }), puppeteer: { /* * headless: false is deliberate — do NOT "fix" this to true. * * WhatsApp's automation detection fingerprints headless Chrome specifically * (navigator.webdriver, the HeadlessChrome UA token, missing GPU/permissions * surface). Running a genuinely headful Chromium under Xvfb inside the * container removes that signal entirely rather than trying to patch around * it. The Dockerfile's `xvfb-run -a node index.js` is what supplies the * virtual display that makes headful possible with no monitor attached. */ headless: false, executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', args: [ // Chromium's sandbox needs privileges this container deliberately does not // have; the container itself is the isolation boundary here. '--no-sandbox', '--disable-setuid-sandbox', // /dev/shm defaults to 64MB in Docker, which crashes Chromium's renderer. '--disable-dev-shm-usage' ] } }); client.on('qr', (qr) => { console.log('--- Scan this QR code with WhatsApp -> Linked devices -> Link a device ---'); qrcode.generate(qr, { small: true }); console.log('--- (docker compose logs -f whatsapp-bridge) ---'); }); client.on('authenticated', () => { console.log(`Authenticated. Session persisted under ${AUTH_PATH}.`); }); client.on('auth_failure', (message) => { console.error(`Authentication failed: ${message}`); console.error(`Delete ${AUTH_PATH} and restart to re-scan the QR code.`); }); client.on('ready', () => { console.log(`Ready. Appending received messages to ${MESSAGES_PATH}.`); }); client.on('disconnected', (reason) => { console.error(`Disconnected: ${reason}. Exiting so the container restart policy reconnects.`); process.exit(1); }); // 'message' fires for incoming messages only ('message_create' would also fire // for our own outgoing ones, which are not what the digest summarises). client.on('message', async (message) => { try { const contact = await message.getContact(); const chat = await message.getChat(); const record = { id: message.id ? message.id._serialized : null, from: message.from, from_name: contact ? (contact.pushname || contact.name || contact.number) : message.from, chat: chat ? chat.name : null, is_group: chat ? Boolean(chat.isGroup) : false, timestamp: message.timestamp, type: message.type, body: message.body || '' }; // appendFileSync opens/appends/closes per message, so whatsapp_ingest.py can // rename the file out from under us mid-run without losing a partial write. fs.appendFileSync(MESSAGES_PATH, JSON.stringify(record) + '\n', 'utf8'); } catch (err) { console.error(`Could not record a message: ${err}`); } }); client.initialize();