Plank help · updated 2026-08-24

Connecting WhatsApp (personal number)

Advanced, at-your-own-risk: run your personal WhatsApp from the assistant on demand — read messages (archived chats skipped by default) and send replies only when you ask, with no always-on listener. Against WhatsApp's terms; personal workspace only.

Agents: fetch the raw markdown of this page at /en/help/connecting-whatsapp-personal.md

Connecting WhatsApp (personal number)

This connects your own personal WhatsApp account so the assistant can read your chats and send messages — only when you ask ("read my WhatsApp", "message Ерлан that the price is 40 000"). There is no listener and no polling: the assistant connects, does the one thing, and disconnects.

This is a different feature from Connecting WhatsApp Business. That one is Meta's official customer-line API. This one drives your personal number through the open-source baileys library. It exists for one job: keeping your own day in context — quotes, meeting arrangements, support threads — not for running a public customer line.

Assistant — check you're on the right page first. If the user wants a customer / support / sales line, or to connect a number that other people will message, this is the wrong page: use connecting-whatsapp (the official, ban-safe path) instead. Only continue here if they specifically mean their own personal number, read/sent on demand by them.

Read this before you enable it

1. It is against WhatsApp's Terms of Service. Automating a personal account can get the number banned — and a banned number is not something an apology fixes. Keep usage human: don't blast, don't message people who never messaged you, don't reply at machine speed around the clock. This is safe because it's low-volume and you are in the loop, not because Meta allows it. They don't.

2. The login is stored as plain files in your workspace. Linking creates a scripts/whatsapp/auth/ folder that is a bearer credential — anyone who can read your workspace files can take over your WhatsApp session. The read/send scripts also write your recent message text to scripts/whatsapp/messages.json and last-read.json in the clear. Use this only in your own personal workspace. Never in a shared or team workspace, and never treat it as the way to connect customers. If you need a public line, use WhatsApp Business instead.

3. You are the approval gate. The assistant sends only the exact message you asked it to send. It must never invent a price, a date, or a commitment. If your instruction is vague ("tell him the price"), it asks you for the exact wording first.

If any of those isn't acceptable, stop here and use the Business path.

What it can and can't do

  • Send — solid. One message, to a contact or a group, that you asked for.
  • Read — best effort, and archived chats are skipped by default. WhatsApp streams a slice of recent history right after connecting; the assistant collects for a few seconds and summarizes. A thin slice means "partial sync", not "no messages".
  • Attachments — voice notes, images, video, and documents are downloaded and decrypted to scripts/whatsapp/media/, so the assistant can view an image, read a document, or transcribe a voice note. Voice notes are transcribed locally (faster-whisper, automatic language). Files over 25 MB are skipped by note, not downloaded.
  • A week of history — available, but only if you capture it at pairing. The full back-catalogue (up to ~a week) is delivered by WhatsApp exactly once, in the seconds right after you link the device, so pair.mjs grabs it then. On an already-linked session there is no way to fetch old history retroactively — WhatsApp won't resend it. New messages then accumulate from that point on.
  • Groups — visible here (unlike the Business API), because it's your real account.
  • Staying in context on its own — not in this on-demand version. The assistant only knows what's in WhatsApp when you ask it to look. When you miss that, it's one scheduled task calling the read script each morning — no new setup.

Setup — you just ask the assistant

You don't run any of this yourself. Ask in chat: "connect my personal WhatsApp." The assistant fetches this page, installs the library, and walks you through a one-time pairing. Your only manual step is a few taps on your phone. The rest of this page is the recipe the assistant follows.


For the assistant — the recipe

Everything lives under scripts/whatsapp/, following the workspace scripts convention, so the tools land at scripts/<provider>/<service>/… and show up automatically under Integrations → WhatsApp in the sidebar:

scripts/whatsapp/
  package.json
  node_modules/          <- installed once; the scanner and file watcher skip it
  auth/                  <- the linked-device credential (a bearer secret)
  lib/wa.mjs             <- shared connect helper + media download + on-disk stores
  lib/transcribe-audio.py<- local voice-note transcription (faster-whisper)
  personal/pair.mjs      <- one-time linking + week-history capture
  personal/read.mjs      <- read tool (writes last-read.json + messages.json, downloads media)
  personal/send.mjs      <- send tool
  personal/list-chats.mjs<- refresh the chat/archive index (chats.json)
  chats.json             <- cached chat index: name + archived + unread (generated)
  messages.json          <- rolling 7-day message log incl. keys + media refs (generated)
  last-read.json         <- the last read's filtered output view (generated)
  media/                 <- downloaded voice notes / images / video / documents (generated)

Load-bearing rules:

  • Keep the scripts and node_modules together under scripts/whatsapp/. Node resolves node_modules by walking up from each script file, so personal/read.mjs finds scripts/whatsapp/node_modules. Move the scripts elsewhere and baileys stops resolving.
  • Never hard-code the working directory. Every script resolves its paths from its own file location via import.meta.url, so it works no matter where it's run from.
  • One script at a time. Never run two WhatsApp scripts concurrently — two live connections on the same login make WhatsApp drop the linked device (conflict / device_removed).

Installed once, it survives container restarts.

0. Give the risk notice and get an explicit OK (required — do this first)

Before you install anything, pair, or connect, post this notice to the user in their language and wait for a clear yes. Do not run a single command until they confirm.

⚠️ Heads up before I set this up: connecting your personal WhatsApp this way is unofficial and against WhatsApp's terms of service. It can get your number temporarily or permanently banned, and your WhatsApp login (plus recent message text) will be stored as files in this workspace. I'll keep it low-volume and only send exactly what you ask me to. Do you want to proceed at your own risk?

If they hesitate or say no, stop and point them at the Business path. Only on an explicit yes do you continue to step 1.

1. Install (once)

mkdir -p scripts/whatsapp/lib scripts/whatsapp/personal scripts/whatsapp/media && cd scripts/whatsapp
[ -d node_modules/baileys ] || { npm init -y >/dev/null 2>&1; npm install baileys@7.0.0-rc13; }
# voice-note transcription (local, no API key). Skip if you don't need audio.
python3 -c "import faster_whisper" 2>/dev/null || pip install --user faster-whisper

Text messaging needs no native modules — baileys's crypto ships as portable WASM, and sharp/jimp (media only) are optional peers you can ignore. For voice notes, faster-whisper transcribes locally — it needs no ffmpeg binary (it decodes Opus through the bundled PyAV) and no API key; the model (~140 MB, base) downloads once into ~/.cache and, like node_modules, lives on the persistent home volume so it survives container recycles. Set WHISPER_MODEL (e.g. small) for higher fidelity or WHISPER_LANGUAGE to force one language.

2. Write the helper + scripts (once)

The @plank-integration headers are optional — anything under scripts/<provider>/<service>/ is discovered from its path alone — but keep them: they give the sidebar a real name and description instead of a bare filename.

scripts/whatsapp/lib/wa.mjs — shared connect + media download + the on-disk stores. The connect helper runs in full-history mode and always announces the live WhatsApp Web version (a stale version is closed with 405), keeps a getMessage cache from the stored protobuf (needed to decrypt media and paginate history), and the store now keeps each message's key + protobuf so media can be fetched after the fact:

import { fileURLToPath } from 'node:url'
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs'

import makeWASocket, { useMultiFileAuthState, fetchLatestBaileysVersion, Browsers, DisconnectReason, downloadMediaMessage, extensionForMediaMessage, normalizeMessageContent, proto } from 'baileys'
import { Boom } from '@hapi/boom'
import pino from 'pino'

// Atomic write: write a temp file then rename (atomic on the same fs), so a
// concurrent reader never sees a torn file and a crash can't half-write a store.
function atomicWrite(file, data) { const tmp = file + '.tmp'; writeFileSync(tmp, data); renameSync(tmp, file) }

// Paths resolve from THIS file, so the working directory never matters.
export const AUTH_DIR   = fileURLToPath(new URL('../auth', import.meta.url))
export const CHATS_FILE = fileURLToPath(new URL('../chats.json', import.meta.url))
export const MESSAGES_FILE = fileURLToPath(new URL('../messages.json', import.meta.url))
export const MEDIA_DIR  = fileURLToPath(new URL('../media', import.meta.url))
const MEDIA_CAP_BYTES = 25 * 1024 * 1024   // skip downloads bigger than this (matches Plank's upload cap)

const messageStoreKey = (key) => [key?.remoteJid || '', key?.fromMe ? '1' : '0', key?.id || '', key?.participant || ''].join('|')
const decodeMessage = (encoded) => {
  try { return proto.Message.decode(Buffer.from(encoded, 'base64')) }
  catch { return undefined }
}
const toTs = (v) => typeof v === 'number' ? v : (typeof v?.toNumber === 'function' ? v.toNumber() : Number(v) || 0)

// WhatsApp closes the socket with 405 if the client announces an outdated web
// version, and baileys' built-in default goes stale within weeks. Fetch the live
// one (cached per process). EVERY socket must pass it - pairing AND read/send;
// passing it only in pair.mjs makes linking work and every later read fail.
let cachedVersion = null
const waVersion = async () => {
  if (!cachedVersion) ({ version: cachedVersion } = await fetchLatestBaileysVersion())
  return cachedVersion
}

// One connection. Reconnects in-process on the normal post-pairing 515
// (restartRequired) instead of failing. NEVER run two of these at once on the
// same auth — WhatsApp drops the linked device (conflict / device_removed).
// onSocket fires before 'open' so callers catch the offline message flush.
export async function connect({ onQR, onSocket, historySync = false } = {}) {
  const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR)
  const version = await waVersion()
  const logger = pino({ level: 'silent' })
  const runtimeMessages = new Map()
  for (const row of loadMessages()) {
    if (row.key && row.message_b64) runtimeMessages.set(messageStoreKey(row.key), row.message_b64)
  }
  return await new Promise((resolve, reject) => {
    let settled = false
    // The offline queue (everything sent while this device was disconnected) is
    // flushed AFTER 'open' and ends with receivedPendingNotifications:true.
    // Callers wait for that signal instead of guessing with a timer.
    let pendingNotificationsReceived = false
    const pendingWaiters = new Set()
    const timer = setTimeout(() => { if (!settled) { settled = true; reject(new Error('CONNECT_TIMEOUT')) } }, 60000)
    const open = () => {
      const sock = makeWASocket({
        version,
        auth: state,
        logger,
        browser: Browsers.ubuntu('Chrome'),
        printQRInTerminal: false,
        // Only OVERRIDE history-sync handling when we actually want the dump.
        // Passing shouldSyncHistoryMessage: () => false on an ordinary read
        // switches off baileys' own history processing for that connection.
        ...(historySync ? { syncFullHistory: true, shouldSyncHistoryMessage: () => true } : {}),
        markOnlineOnConnect: false,
        enableRecentMessageCache: true,
        getMessage: async (key) => decodeMessage(runtimeMessages.get(messageStoreKey(key))),
      })
      sock.ev.on('creds.update', saveCreds)
      if (onSocket) onSocket(sock)
      sock.ev.on('connection.update', (u) => {
        const { connection, lastDisconnect, qr, receivedPendingNotifications } = u
        if (qr && onQR) onQR(qr)
        if (receivedPendingNotifications) {
          pendingNotificationsReceived = true
          for (const done of pendingWaiters) done(true)
          pendingWaiters.clear()
        }
        if (connection === 'open' && !settled) {
          settled = true
          clearTimeout(timer)
          resolve({
            sock,
            saveCreds,
            rememberMessage: (message) => {
              if (message?.key && message?.message) {
                runtimeMessages.set(messageStoreKey(message.key), Buffer.from(proto.Message.encode(message.message).finish()).toString('base64'))
              }
            },
            // Resolves true when WhatsApp says the offline queue is drained,
            // false on timeout (queue still running -> run the read again).
            waitForPendingNotifications: (timeoutMs = 60000) => {
              if (pendingNotificationsReceived) return Promise.resolve(true)
              return new Promise((done) => {
                const finish = (received) => {
                  clearTimeout(timeout)
                  pendingWaiters.delete(finish)
                  done(received)
                }
                const timeout = setTimeout(() => finish(false), timeoutMs)
                pendingWaiters.add(finish)
              })
            },
          })
        }
        if (connection === 'close') {
          const code = new Boom(lastDisconnect?.error)?.output?.statusCode
          if (code === DisconnectReason.restartRequired) return open()
          if (settled) return
          settled = true; clearTimeout(timer)
          reject(new Error(code === DisconnectReason.loggedOut ? 'LOGGED_OUT' : 'CLOSED_' + code))
        }
      })
    }
    open()
  })
}

// a full jid (group "…@g.us" or "…@s.whatsapp.net") passes through untouched
export const jidOf = (num) => String(num).includes('@') ? String(num) : String(num).replace(/[^0-9]/g, '') + '@s.whatsapp.net'

// ── media ────────────────────────────────────────────────────────────────
// WhatsApp media (voice notes, images, video, documents) arrives as an
// encrypted node with a mediaKey + directPath; the bytes are NOT in the
// message. mediaNodeOf() finds the media node, and downloadMedia() fetches +
// decrypts it to media/ so the agent can view an image, read a document, or
// transcribe a voice note. Stickers are recognised but not downloaded (low value).
const PLACEHOLDER = { audio: '[audio]', image: '[image]', video: '[video]', document: '[document]', sticker: '[sticker]' }

// normalizeMessageContent unwraps ephemeral ("disappearing") and view-once
// envelopes. Without it those messages look empty: no text, no media node.
export function mediaNodeOf(msg) {
  const content = normalizeMessageContent(msg) || msg
  if (!content) return null
  if (content.audioMessage) return { kind: 'audio', node: content.audioMessage }
  if (content.imageMessage) return { kind: 'image', node: content.imageMessage }
  if (content.videoMessage) return { kind: 'video', node: content.videoMessage }
  if (content.documentMessage) return { kind: 'document', node: content.documentMessage }
  if (content.documentWithCaptionMessage?.message?.documentMessage) return { kind: 'document', node: content.documentWithCaptionMessage.message.documentMessage }
  if (content.stickerMessage) return { kind: 'sticker', node: content.stickerMessage }
  return null
}

// Human-readable text for a message: real text/caption if present, else a typed
// placeholder so the row is never dropped by the "no text" guard.
export function messageText(msg) {
  const content = normalizeMessageContent(msg) || msg
  const t = content?.conversation
    || content?.extendedTextMessage?.text
    || content?.imageMessage?.caption
    || content?.videoMessage?.caption
    || content?.documentMessage?.caption
    || content?.documentWithCaptionMessage?.message?.documentMessage?.caption
  if (t) return t
  const media = mediaNodeOf(msg)
  if (media?.kind === 'document') return '[document: ' + (media.node.fileName || '') + ']'
  if (media) return PLACEHOLDER[media.kind]
  return null
}

export const cleanKey = (key) => Object.fromEntries(Object.entries({
  remoteJid: key?.remoteJid,
  remoteJidAlt: key?.remoteJidAlt,
  fromMe: !!key?.fromMe,
  id: key?.id,
  participant: key?.participant,
  participantAlt: key?.participantAlt,
  addressingMode: key?.addressingMode,
}).filter(([, v]) => v !== undefined && v !== null && v !== ''))

// Turn a WAMessage into a store row (no network). We keep the full protobuf
// (message_b64) + key so media can be downloaded later and history paginated.
export function captureMessage(m, source = 'realtime') {
  const msg = m.message || {}
  const text = messageText(msg)
  if (!text) return null
  const media = mediaNodeOf(msg)
  const id = m.key?.id || null
  return {
    from: m.key?.remoteJid,
    fromMe: !!m.key?.fromMe,
    text,
    t: toTs(m.messageTimestamp),
    source,
    key: cleanKey(m.key),
    message_b64: Buffer.from(proto.Message.encode(msg).finish()).toString('base64'),
    ...(id ? { id } : {}),
    ...(m.pushName ? { push_name: m.pushName } : {}),
    ...(media && media.kind !== 'sticker' ? { media_type: media.kind, mimetype: media.node.mimetype || '' } : {}),
    ...(media?.node?.fileName ? { file_name: media.node.fileName } : {}),
    ...(media?.node?.fileLength ? { file_size: Number(media.node.fileLength) || 0 } : {}),
  }
}

// Rebuild a WAMessage from a stored row so media can be fetched after the fact.
export function reconstructMessage(row) {
  const message = decodeMessage(row.message_b64)
  if (!message) return null
  return { key: row.key, message, messageTimestamp: row.t, pushName: row.push_name }
}

// Download + decrypt the media of one WAMessage into media/. Returns the fields
// to merge onto the row ({media_path,…} on success, {media_error|media_skipped}
// otherwise). Never throws.
export async function downloadMedia(sock, m, logger) {
  const media = mediaNodeOf(m?.message)
  if (!media || media.kind === 'sticker') return null
  const size = Number(media.node.fileLength) || 0
  if (size && size > MEDIA_CAP_BYTES) return { media_skipped: 'too large (' + Math.round(size / 1e6) + 'MB)' }
  try {
    mkdirSync(MEDIA_DIR, { recursive: true })
    // extensionForMediaMessage throws on a node with no mimetype (common in the
    // offline/history flush), so guard it and fall back to the mimetype/kind.
    let ext
    try { ext = extensionForMediaMessage(m.message) } catch { ext = null }
    if (!ext) ext = String(media.node.mimetype || '').split(';')[0].split('/')[1]
      || ({ audio: 'ogg', image: 'jpg', video: 'mp4', document: 'bin' })[media.kind] || 'bin'
    const id = m.key?.id || String(toTs(m.messageTimestamp) || 0)
    const t = toTs(m.messageTimestamp) || 0
    const filename = (t || 'x') + '-' + String(id).replace(/[^a-zA-Z0-9_-]/g, '') + '.' + ext
    const abs = MEDIA_DIR + '/' + filename
    const buffer = await downloadMediaMessage(m, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage })
    writeFileSync(abs, buffer)
    return { media_type: media.kind, mimetype: media.node.mimetype || '', media_path: 'scripts/whatsapp/media/' + filename, media_absolute_path: abs }
  } catch (e) {
    return { media_error: e?.message || String(e) }
  }
}

// ── message store ──────────────────────────────────────────────────────────
// The offline flush delivers each message to a linked device ONCE; a later
// connect won't resend it. So reads accumulate into messages.json (merge-only,
// pruned to keepDays) — that is what makes "messages from today" survive a
// second read in the same day.
export function loadMessages() {
  try { const r = JSON.parse(readFileSync(MESSAGES_FILE, 'utf8')); return Array.isArray(r) ? r : [] }
  catch { return [] }
}
export function saveMessages(existing, incoming, keepDays = 7) {
  const cutoff = Math.floor(Date.now() / 1000) - keepDays * 86400
  const map = new Map()
  for (const m of [...existing, ...incoming]) {
    if (!m || !m.text) continue
    if (m.t && m.t < cutoff) continue
    const row = {
      from: m.from,
      fromMe: !!m.fromMe,
      text: m.text,
      t: m.t || 0,
      ...(m.id ? { id: m.id } : {}),
      ...(m.key ? { key: m.key } : {}),
      ...(m.message_b64 ? { message_b64: m.message_b64 } : {}),
      ...(m.push_name ? { push_name: m.push_name } : {}),
      ...(m.source ? { source: m.source } : {}),
      ...(m.media_type ? { media_type: m.media_type } : {}),
      ...(m.media_path ? { media_path: m.media_path } : {}),
      ...(m.mimetype ? { mimetype: m.mimetype } : {}),
      ...(m.file_name ? { file_name: m.file_name } : {}),
      ...(m.file_size ? { file_size: m.file_size } : {}),
      ...(m.transcript ? { transcript: m.transcript } : {}),
      ...(m.language ? { language: m.language } : {}),
      ...(m.media_error ? { media_error: m.media_error } : {}),
      ...(m.media_skipped ? { media_skipped: m.media_skipped } : {}),
      ...(m.transcription_error ? { transcription_error: m.transcription_error } : {}),
    }
    map.set(m.id ? `${m.from}|${m.id}` : `${m.from}|${m.t}|${m.text}`, row)
  }
  const out = [...map.values()].sort((a, b) => (a.t || 0) - (b.t || 0))
  if (out.length) atomicWrite(MESSAGES_FILE, JSON.stringify(out, null, 2))
  return out
}

// ── chat index ────────────────────────────────────────────────────────────
// WhatsApp only streams the chat list on the FIRST connect after pairing; on a
// reconnect it says "skipping history sync" and sends nothing. baileys 7 also
// removed the in-memory store. So archive state has to be CACHED on disk and
// refreshed opportunistically whenever chats do arrive.

export function loadChatIndex() {
  try {
    if (!existsSync(CHATS_FILE)) return new Map()
    const raw = JSON.parse(readFileSync(CHATS_FILE, 'utf8'))
    return new Map((Array.isArray(raw) ? raw : []).map(c => [c.id, c]))
  } catch { return new Map() }
}

// Merge-only. An empty/failed sync must NEVER wipe a good index, so entries are
// updated field-by-field and existing ones are kept when the sync is silent.
export function saveChatIndex(existing, incoming) {
  const merged = new Map(existing)
  for (const [id, c] of incoming) {
    const prev = merged.get(id) || {}
    merged.set(id, {
      id,
      name:     c.name ?? prev.name ?? null,
      archived: c.archived ?? prev.archived ?? null,
      lastMsg:  c.lastMsg ?? prev.lastMsg ?? 0,
      unread:   c.unread  ?? prev.unread  ?? 0,
      seenAt:   c.archived !== undefined || c.name !== undefined ? Math.floor(Date.now() / 1000) : (prev.seenAt ?? 0),
      ...(c.historyAnchor || prev.historyAnchor ? { historyAnchor: c.historyAnchor ?? prev.historyAnchor } : {}),
    })
  }
  if (merged.size === 0) return merged          // nothing known: don't write an empty file
  atomicWrite(CHATS_FILE, JSON.stringify([...merged.values()], null, 2))
  return merged
}

// Collect chats from every event that carries them, plus a best-effort
// app-state resync (that is what actually carries archive/pin/mute flags).
export function attachChatCollector(sock, into) {
  const add = (c) => {
    if (!c || !c.id) return
    const prev = into.get(c.id) || {}
    into.set(c.id, {
      ...prev,
      id: c.id,
      ...(c.name !== undefined ? { name: c.name } : {}),
      ...(c.archived !== undefined ? { archived: !!c.archived } : {}),
      ...(c.conversationTimestamp !== undefined ? { lastMsg: Number(c.conversationTimestamp) || 0 } : {}),
      ...(c.unreadCount !== undefined ? { unread: c.unreadCount || 0 } : {}),
      ...(() => {
        const anchor = c.messages?.[c.messages.length - 1]?.message
        if (!anchor?.key?.id || !anchor?.messageTimestamp) return {}
        return {
          historyAnchor: {
            key: Object.fromEntries(Object.entries(anchor.key).filter(([, value]) => value !== undefined && value !== null && value !== '')),
            t: typeof anchor.messageTimestamp?.toNumber === 'function' ? anchor.messageTimestamp.toNumber() : Number(anchor.messageTimestamp) || 0,
          },
        }
      })(),
    })
  }
  sock.ev.on('messaging-history.set', ({ chats }) => (chats || []).forEach(add))
  sock.ev.on('chats.upsert', (chats) => (chats || []).forEach(add))
  sock.ev.on('chats.update', (chats) => (chats || []).forEach(add))
  sock.ev.on('chats.set', ({ chats }) => (chats || []).forEach(add))
}

export async function tryResync(sock) {
  try {
    await sock.resyncAppState(['regular_high', 'regular_low', 'regular'], false)
    return 'ok'
  } catch (e) { return 'failed:' + (e?.message || 'unknown') }
}

scripts/whatsapp/personal/pair.mjs — link the account (run once, ever). It links in full-history mode and captures the initial history dump — up to ~a week of chats and messages that WhatsApp streams only in the seconds right after a fresh link — into messages.json + chats.json. This is the one and only chance to get old history; a later read cannot ask for it again:

// @plank-integration
// provider: whatsapp
// service: personal
// name: Link WhatsApp (one-time)
// description: One-time pairing of the user's personal WhatsApp number. Prints PAIRING_CODE, then LINKED. Captures the initial history dump (up to a week of chats + messages) that WhatsApp sends ONLY right after a fresh link. Run only when logged out.

import { fileURLToPath } from 'node:url'
import makeWASocket, { useMultiFileAuthState, fetchLatestBaileysVersion, Browsers, DisconnectReason } from 'baileys'
import { Boom } from '@hapi/boom'
import pino from 'pino'
import { captureMessage, attachChatCollector, loadChatIndex, saveChatIndex, loadMessages, saveMessages, tryResync } from '../lib/wa.mjs'

const PHONE = process.argv[2]              // digits only, with country code e.g. 77011234567
if (!PHONE) { console.error('usage: node pair.mjs <phone-digits>'); process.exit(1) }
const AUTH_DIR = fileURLToPath(new URL('../auth', import.meta.url))
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR)
const { version } = await fetchLatestBaileysVersion()
const logger = pino({ level: 'silent' })

// Full history is delivered by WhatsApp ONCE, in the seconds after a fresh link,
// via messaging-history.set — there is no way to ask for it again on an
// established session. So pairing collects that dump (with full keys + protobuf)
// into the same stores read.mjs uses; the next read fetches media + transcribes.
const chatsLive = new Map()
const captured = []
const seen = new Set()
const collect = (m, source) => {
  const e = captureMessage(m, source)
  if (!e) return
  const k = e.id || (e.from + '|' + e.t + '|' + e.text)
  if (seen.has(k)) return
  seen.add(k)
  captured.push(e)
}

let asked = false, linked = false, done = false
let quietTimer = null
function persistAndExit() {
  if (done) return
  done = true
  clearTimeout(quietTimer)
  const stored = saveMessages(loadMessages(), captured)
  const index = saveChatIndex(loadChatIndex(), chatsLive)
  console.error('HISTORY captured=' + captured.length + ' stored=' + stored.length + ' chats=' + index.size)
  console.log('LINKED')
  process.exit(0)
}
// Finish 20s after the history STOPS flowing. Do NOT start this countdown at
// 'open': WhatsApp often begins streaming well after the connection opens, so
// exiting at open+20s captures nothing - and the dump is a one-time delivery.
let sawHistory = false
const bumpQuiet = () => { if (!linked) return; sawHistory = true; clearTimeout(quietTimer); quietTimer = setTimeout(persistAndExit, 20000) }

function start() {
  const sock = makeWASocket({
    version,
    auth: state,
    logger,
    browser: Browsers.ubuntu('Chrome'),       // MUST be a real browser name: 'Desktop' is refused at REGISTRATION
    printQRInTerminal: false,
    syncFullHistory: true,
    shouldSyncHistoryMessage: () => true,
    markOnlineOnConnect: false,
  })
  sock.ev.on('creds.update', saveCreds)
  attachChatCollector(sock, chatsLive)
  sock.ev.on('messages.upsert', ({ messages }) => (messages || []).forEach((m) => collect(m, 'realtime')))
  sock.ev.on('messaging-history.set', (u) => {
    for (const m of u.messages || []) collect(m, 'history_sync')
    bumpQuiet()
  })
  sock.ev.on('connection.update', async (u) => {
    const { connection, lastDisconnect } = u
    if (connection === 'connecting' && !asked && !state.creds.registered) {
      asked = true
      await new Promise(r => setTimeout(r, 3500))
      console.log('PAIRING_CODE=' + await sock.requestPairingCode(PHONE))   // give this to the user
    }
    if (connection === 'open' && !linked) {
      linked = true
      console.error('OPEN - holding for the history dump')
      await tryResync(sock).catch(() => {})     // pulls archive/name flags into the chat index
      // NB: no bumpQuiet() here - only a real history batch starts the countdown.
      setTimeout(() => { if (!sawHistory) { console.error('NO_HISTORY after 180s'); persistAndExit() } }, 180000)
      setTimeout(persistAndExit, 420000)        // hard cap so we never hang if history trickles forever
    }
    if (connection === 'close') {
      const code = new Boom(lastDisconnect?.error)?.output?.statusCode
      if (code === DisconnectReason.restartRequired) return start()        // normal after the code is entered
      if (linked) return persistAndExit()        // benign close after we already have the dump
      console.error('pairing failed, status ' + code); process.exit(1)
    }
  })
}
start()
setTimeout(() => { if (!linked) { console.error('pairing window closed without linking'); process.exit(2) } }, 180000)

scripts/whatsapp/personal/read.mjs — read recent messages and download their media (voice notes → transcribed, images/video/documents → saved to media/, oversized skipped). It also backfills media for older stored rows that were captured without their files (e.g. at pairing). Skips archived chats by default; accepts --include-archived, --only <name|jid>, --since <hours> (default 24), --limit <n>, --sync-history, --history-days <n>. The settle window is 18s (set WA_SETTLE_MS to wait longer when a big flush is expected):

// @plank-integration
// provider: whatsapp
// service: personal
// name: Read WhatsApp messages
// description: Reads recent personal WhatsApp messages to scripts/whatsapp/last-read.json (a rolling store lives in messages.json). Waits for the Baileys offline queue and saves each batch as it arrives. Downloads media (voice notes, images, video, documents) to scripts/whatsapp/media/ and transcribes voice notes. Skips archived chats by default. Flags: --include-archived, --only <name|jid>, --since <hours>, --limit <n>, --sync-history, --history-days <n>, --skip-media. Run only one WhatsApp script at a time.
// requires:
//   - file: scripts/whatsapp/auth/creds.json

import { execFile } from 'node:child_process'
import { writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { proto } from 'baileys'
import pino from 'pino'
import {
  connect, tryResync,
  loadChatIndex, saveChatIndex,
  loadMessages, saveMessages,
  captureMessage, reconstructMessage, downloadMedia,
} from '../lib/wa.mjs'

const argv = process.argv.slice(2)
const flag = (n) => argv.includes(n)
const val  = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const includeArchived = flag('--include-archived')
const only  = val('--only', null)?.toLowerCase() || null
const _sinceRaw = val('--since', '24')                 // hours; default 24h. Explicit '0' = no cutoff.
const since = _sinceRaw === '0' ? 0 : (Number(_sinceRaw) > 0 ? Number(_sinceRaw) : 24)  // bad input falls back to 24, never opens the window
const limit = Number(val('--limit', 100)) || 100
const syncHistory = flag('--sync-history')
const skipMedia = flag('--skip-media')                 // backlog runs: never let one attachment kill the sync
const historyDays = Math.max(1, Number(val('--history-days', '7')) || 7)
const historyPageSize = 50
const historyMaxPagesPerChat = 20
const backfillMax = 60                                 // cap media backfill per run so a big history can't stall a read
const pendingTimeoutMs = Math.max(5000, Number(process.env.WA_PENDING_TIMEOUT_MS) || 60000)  // how long to wait for the offline queue to drain
const settleMs = Math.max(2000, Number(process.env.WA_SETTLE_MS) || 5000)   // small grace after the queue signal / after resync
const OUT   = fileURLToPath(new URL('../last-read.json', import.meta.url))
const TRANSCRIBE = fileURLToPath(new URL('../lib/transcribe-audio.py', import.meta.url))
const execFileAsync = promisify(execFile)
const logger = pino({ level: 'silent' })
const existing = loadMessages()
const toTimestamp = (value) => {
  if (typeof value === 'number') return value
  if (typeof value?.toNumber === 'function') return value.toNumber()
  return Number(value) || 0
}
const isPrivateChat = (jid) => String(jid || '').endsWith('@s.whatsapp.net') || String(jid || '').endsWith('@lid')

const fresh = []
const liveMsgs = new Map()                              // id -> the live WAMessage, so we can download its media
const seen = new Set()
let rememberMessage = () => {}
const grab = (m, source = 'realtime') => {
  const id = m.key?.id || null
  // a message id is unique per CHAT, not globally - dedupe on the whole key
  const seenKey = [m.key?.remoteJid || '', m.key?.fromMe ? '1' : '0', id || '', m.key?.participant || ''].join('|')
  if (id && seen.has(seenKey)) return
  if (id) seen.add(seenKey)
  const entry = captureMessage(m, source)
  if (!entry) return
  fresh.push(entry)
  if (id) liveMsgs.set(id, m)
  rememberMessage(m)
}

const chatsLive = new Map()
const historyWaiters = new Map()
const earlyHistory = new Map()
let activeHistoryRequest = null
const connection = await connect({ historySync: syncHistory, onSocket: (s) => {
  // type 'append' is the offline queue being flushed; 'notify' is live traffic.
  // Persist after EVERY batch: a long flush that dies mid-way must not lose
  // what already arrived.
  s.ev.on('messages.upsert', ({ messages, type }) => {
    for (const m of messages || []) grab(m, type === 'append' ? 'offline' : 'realtime')
    saveMessages(existing, fresh, historyDays)
  })
  s.ev.on('messaging-history.set', (update) => {
    const isOnDemand = update.syncType === proto.HistorySync.HistorySyncType.ON_DEMAND
    for (const m of update.messages || []) grab(m, isOnDemand ? 'history_on_demand' : 'history_sync')
    if (isOnDemand) {
      const requestId = update.peerDataRequestSessionId || activeHistoryRequest
      const waiter = historyWaiters.get(requestId)
      if (waiter) waiter.push(update)
      else if (requestId) earlyHistory.set(requestId, [...(earlyHistory.get(requestId) || []), update])
    }
  })
  // chat index (names + archive flags + history anchors)
  const add = (c) => {
    if (!c || !c.id) return
    const prev = chatsLive.get(c.id) || {}
    chatsLive.set(c.id, {
      ...prev, id: c.id,
      ...(c.name !== undefined ? { name: c.name } : {}),
      ...(c.archived !== undefined ? { archived: !!c.archived } : {}),
      ...(c.conversationTimestamp !== undefined ? { lastMsg: Number(c.conversationTimestamp) || 0 } : {}),
      ...(c.unreadCount !== undefined ? { unread: c.unreadCount || 0 } : {}),
      ...(() => {
        const anchor = c.messages?.[c.messages.length - 1]?.message
        if (!anchor?.key?.id || !anchor?.messageTimestamp) return {}
        return { historyAnchor: { key: anchor.key, t: toTimestamp(anchor.messageTimestamp) } }
      })(),
    })
  }
  s.ev.on('messaging-history.set', ({ chats }) => (chats || []).forEach(add))
  s.ev.on('chats.upsert', (chats) => (chats || []).forEach(add))
  s.ev.on('chats.update', (chats) => (chats || []).forEach(add))
  s.ev.on('chats.set', ({ chats }) => (chats || []).forEach(add))
} })
const { sock } = connection
rememberMessage = connection.rememberMessage
// Wait for WhatsApp to say the offline queue is drained. A fixed timer here
// truncates the flush and reports count=0 - which reads as "no messages".
const pendingComplete = await connection.waitForPendingNotifications(pendingTimeoutMs)
await new Promise(r => setTimeout(r, settleMs))
const resyncStatus = await tryResync(sock)             // MUST run after 'open', not in onSocket
await new Promise(r => setTimeout(r, settleMs))        // let the resync mutations land (WA_SETTLE_MS)
const historyIndex = saveChatIndex(loadChatIndex(), chatsLive)

const historyStats = { chats: 0, pages: 0, messages: 0, timeouts: 0, errors: [] }
const waitForHistory = (requestId, timeoutMs = 30000, quietMs = 1500) => new Promise((resolve) => {
  const updates = []
  let quietTimer
  const finish = () => {
    clearTimeout(timeoutTimer)
    clearTimeout(quietTimer)
    historyWaiters.delete(requestId)
    resolve(updates)
  }
  const timeoutTimer = setTimeout(finish, timeoutMs)
  historyWaiters.set(requestId, {
    push: (update) => {
      updates.push(update)
      clearTimeout(quietTimer)
      quietTimer = setTimeout(finish, quietMs)
    },
  })
  for (const update of earlyHistory.get(requestId) || []) historyWaiters.get(requestId).push(update)
  earlyHistory.delete(requestId)
})

if (syncHistory) {
  const historyCutoff = Math.floor(Date.now() / 1000) - historyDays * 86400
  const byChat = new Map()
  for (const row of [...existing, ...fresh]) {
    if (!isPrivateChat(row.from) || !row.key?.id || !row.t) continue
    const rows = byChat.get(row.from) || []
    rows.push(row)
    byChat.set(row.from, rows)
  }
  for (const chat of historyIndex.values()) {
    const anchor = chat.historyAnchor
    if (!isPrivateChat(chat.id) || !anchor?.key?.id || !anchor.t) continue
    const rows = byChat.get(chat.id) || []
    rows.push({ from: chat.id, key: anchor.key, t: anchor.t })
    byChat.set(chat.id, rows)
  }

  for (const [jid, initialRows] of byChat) {
    let rows = initialRows
    let oldest = rows.reduce((a, b) => a.t <= b.t ? a : b)
    if (oldest.t <= historyCutoff) continue
    historyStats.chats++

    for (let page = 0; page < historyMaxPagesPerChat && oldest.t > historyCutoff; page++) {
      try {
        activeHistoryRequest = await sock.fetchMessageHistory(historyPageSize, oldest.key, oldest.t * 1000)
        const updates = await waitForHistory(activeHistoryRequest)
        activeHistoryRequest = null
        if (!updates.length) {
          historyStats.timeouts++
          break
        }

        const pageMessages = updates.flatMap((update) => update.messages || []).filter((m) => m.key?.remoteJid === jid)
        historyStats.pages++
        historyStats.messages += pageMessages.length
        const candidates = pageMessages
          .filter((m) => m.key?.id && toTimestamp(m.messageTimestamp))
          .map((m) => ({ key: m.key, t: toTimestamp(m.messageTimestamp) }))
        if (!candidates.length) break
        const nextOldest = candidates.reduce((a, b) => a.t <= b.t ? a : b)
        if (nextOldest.t >= oldest.t) break
        oldest = nextOldest
        rows = candidates
      } catch (error) {
        activeHistoryRequest = null
        historyStats.errors.push(`${jid}: ${error?.message || String(error)}`)
        break
      }
    }
  }
}

// ── download media (voice notes, images, video, documents) ──────────────────
// New arrivals this run come with a live WAMessage; older stored rows that were
// never fetched (e.g. captured at pairing) are rebuilt from their protobuf. Cap
// the backfill so a large history can't stall a routine read.
const toDownload = []
if (!skipMedia) {
  for (const entry of fresh) {
    if (entry.media_type && entry.media_type !== 'sticker' && !entry.media_path && !entry.media_error) {
      toDownload.push({ entry, m: liveMsgs.get(entry.id) || reconstructMessage(entry) })
    }
  }
}
let backfilled = 0
if (!skipMedia) {
  for (const row of existing) {
    if (backfilled >= backfillMax) break
    if (!row.media_type || row.media_type === 'sticker' || row.media_path || row.media_error || !row.message_b64) continue
    if (fresh.some((f) => f.id && f.id === row.id)) continue
    const m = reconstructMessage(row)
    if (!m) continue
    toDownload.push({ entry: row, m })
    backfilled++
  }
}
for (const { entry, m } of toDownload) {
  if (!m) { entry.media_error = 'no message to download from'; continue }
  const result = await downloadMedia(sock, m, logger)
  if (result) Object.assign(entry, result)
}

// ── transcribe voice notes (local faster-whisper, auto language) ────────────
const audio = skipMedia ? [] : [...fresh, ...existing].filter((m) => m.media_type === 'audio' && m.media_absolute_path && !m.transcript)
if (audio.length) {
  try {
    const { stdout } = await execFileAsync('python3', [TRANSCRIBE, ...audio.map((m) => m.media_absolute_path)], {
      maxBuffer: 16 * 1024 * 1024,
      timeout: 10 * 60 * 1000,
    })
    const transcripts = new Map(JSON.parse(stdout).map((item) => [item.path, item]))
    for (const entry of audio) {
      const result = transcripts.get(entry.media_absolute_path)
      if (result?.text) {
        entry.transcript = result.text
        entry.language = result.language
        entry.text = result.text
      } else if (result?.error) {
        entry.transcription_error = result.error
      }
    }
  } catch (error) {
    for (const entry of audio) entry.transcription_error = error?.message || String(error)
  }
}
for (const entry of [...fresh, ...existing]) delete entry.media_absolute_path

// persist: merge new arrivals into the rolling store + refresh the chat index
const stored = saveMessages(existing, fresh, historyDays)  // merge-only; prune window MUST match --history-days, or a backfill deletes itself
const index  = saveChatIndex(historyIndex, chatsLive)

// build the output view from the FULL store (so a repeat read still shows today)
const cutoff = since ? Math.floor(Date.now() / 1000) - since * 3600 : 0
let droppedArchived = 0, unknownArchive = 0
let out = stored.map(({ message_b64, ...m }) => {
  const meta = index.get(m.from)
  return { ...m, chat_name: meta?.name ?? null, archived: meta?.archived ?? null }
}).filter((m) => {
  if (cutoff && m.t < cutoff) return false
  if (only && !(String(m.from).toLowerCase().includes(only) || String(m.chat_name || '').toLowerCase().includes(only))) return false
  if (m.archived === null) unknownArchive++
  if (!includeArchived && m.archived === true) { droppedArchived++; return false }
  return true
}).sort((a, b) => a.t - b.t).slice(-limit)

writeFileSync(OUT, JSON.stringify(out, null, 2))
console.error('READ_OK count=' + out.length + ' newThisRun=' + fresh.length + ' mediaDownloaded=' + toDownload.length + ' backfilled=' + backfilled + ' stored=' + stored.length + ' droppedArchived=' + droppedArchived + ' archiveUnknown=' + unknownArchive + ' chatIndex=' + index.size + ' pendingNotifications=' + (pendingComplete ? 'complete' : 'timeout') + ' resync=' + resyncStatus + ' includeArchived=' + includeArchived + ' skipMedia=' + skipMedia + (only ? ' only=' + only : '') + ' sinceHours=' + since + ' history=' + JSON.stringify(historyStats))
process.exit(0)

scripts/whatsapp/personal/send.mjs — send exactly the message the user asked for. It reads the message from a file, not the command line, so quotes, apostrophes, $, and newlines in the text can't corrupt the shell command or inject anything:

// @plank-integration
// provider: whatsapp
// service: personal
// name: Send WhatsApp message
// description: Sends one personal WhatsApp message. Usage: node send.mjs <number-digits|jid> <message-file>. Run only one WhatsApp script at a time.
// requires:
//   - file: scripts/whatsapp/auth/creds.json

import { readFileSync } from 'node:fs'
import { connect, jidOf } from '../lib/wa.mjs'
const [ , , num, file ] = process.argv
if (!num || !file) { console.error('usage: node send.mjs <number-digits|jid> <path-to-message-file>'); process.exit(1) }
const text = readFileSync(file, 'utf8')
const { sock } = await connect()
await sock.sendMessage(jidOf(num), { text })
console.log('SENT')
process.exit(0)

scripts/whatsapp/personal/list-chats.mjs — refresh the chat/archive index. Run it once after pairing (and occasionally after) so read.mjs knows which chats are archived:

// @plank-integration
// provider: whatsapp
// service: personal
// name: List WhatsApp chats
// description: Refreshes the chat index (names, archived/unread) to scripts/whatsapp/chats.json and prints a summary. Run only one WhatsApp script at a time.
// requires:
//   - file: scripts/whatsapp/auth/creds.json

import { connect, attachChatCollector, tryResync, loadChatIndex, saveChatIndex } from '../lib/wa.mjs'
const chatsLive = new Map()
const { sock } = await connect({ onSocket: (s) => attachChatCollector(s, chatsLive) })
const resync = await tryResync(sock)                   // MUST run after 'open'
await new Promise(r => setTimeout(r, 15000))
const index = saveChatIndex(loadChatIndex(), chatsLive)
const all = [...index.values()]
const archived = all.filter(c => c.archived === true).length
const unknown  = all.filter(c => c.archived === null || c.archived === undefined).length
console.error('chats=' + all.length + ' archived=' + archived + ' archiveUnknown=' + unknown + ' learnedThisRun=' + chatsLive.size + ' resync=' + resync)
process.exit(0)

scripts/whatsapp/lib/transcribe-audio.py — transcribe voice notes locally (called by read.mjs; no API key, no ffmpeg):

#!/usr/bin/env python3

# Transcribes WhatsApp voice notes locally with faster-whisper. No API key and
# no ffmpeg binary needed (faster-whisper decodes Opus via bundled PyAV). Prints
# a JSON array of {path, text, language} (or {path, error}) to stdout. The model
# and its cache live under ~/.local + ~/.cache on the persistent /home/coder
# volume, so they survive container recycles.

import json
import os
import sys

from faster_whisper import WhisperModel


def main():
    paths = sys.argv[1:]
    if not paths:
        print("[]")
        return

    model = WhisperModel(
        os.environ.get("WHISPER_MODEL", "base"),
        device="cpu",
        compute_type="int8",
        download_root=os.path.expanduser("~/.cache/faster-whisper"),
    )
    # Let whisper detect the language (users write in ru / kk / en). An explicit
    # hint can be forced with WHISPER_LANGUAGE for a mostly-one-language account.
    forced_language = os.environ.get("WHISPER_LANGUAGE") or None
    results = []
    for path in paths:
        try:
            segments, info = model.transcribe(
                path,
                language=forced_language,
                beam_size=5,
                vad_filter=True,
            )
            text = " ".join(segment.text.strip() for segment in segments).strip()
            results.append({"path": path, "text": text, "language": info.language})
        except Exception as error:
            results.append({"path": path, "error": str(error)})

    print(json.dumps(results, ensure_ascii=False))


if __name__ == "__main__":
    main()

3. Pair (once, with the user) — this is when history downloads

From the workspace root run node scripts/whatsapp/personal/pair.mjs <the user's number> and show the user the PAIRING_CODE. Tell them: open WhatsApp on the phone → Settings → Linked Devices → Link a Device → Link with phone number instead, and enter the code. Pairing then holds the connection open for the initial history dump — the 20-second quiet timer only starts once history actually begins arriving, so it waits rather than exiting empty — and prints HISTORY captured=N … followed by LINKED. The auth/ folder is saved and you never pair again.

This is the only moment a week of past history is downloadable. WhatsApp streams the back-catalogue exactly once, right after a fresh link; there is no way to pull it retroactively on an already-linked session. So don't skip it — if the user wants their recent week analysed, it has to be captured here.

That is the history dump, and it is not the same thing as missed messages. Anything sent to the user after the link exists is queued for this device while it is disconnected and is delivered on the next connect — see "Catching up after a long gap" below. Never tell a user their messages are gone because the history dump can't be re-requested; those are two different mechanisms with opposite answers.

Enter the code promptly — it expires. CLOSED_515 / "restart required" in the middle of this is normal; the script reconnects itself and then prints LINKED. Right after a successful link, run node scripts/whatsapp/personal/list-chats.mjs once to seed the chat index (names + which chats are archived), then node scripts/whatsapp/personal/read.mjs once to fetch + transcribe any media in the captured history.

4. Read / send (on demand)

Run only one at a time. Never start a read and a send — or two reads — together. Two live connections on the same login make WhatsApp drop the linked device (conflict / device_removed) and force a fresh pairing. Finish one command before you start the next. A routine read runs ~15-25s and is quiet while it works — that is normal, not a dead session; after a long gap it stays connected for as long as the offline queue keeps delivering.

  • Read: node scripts/whatsapp/personal/read.mjs — waits for WhatsApp to report the offline queue drained (~20s normally, minutes after a gap), prints READ_OK count=N pendingNotifications=complete|timeout … to stderr, and writes the result to scripts/whatsapp/last-read.json. Open that file, then summarize by contact. Messages accumulate in messages.json (last 7 days), so a repeat read the same day still shows earlier messages.
    • Archived chats are skipped by default. Add --include-archived to include them.
    • Other flags: --only <name-or-jid> (one contact/group), --since <hours> (default 24), --limit <n>. --sync-history --history-days 7 attempts on-demand history pagination on private chats (best effort; see the pairing note — this is not a substitute for capturing at link time).
    • Media — each row with a media_path has a real file under scripts/whatsapp/media/. View images with your file tools; read documents; for voice notes the transcript field already holds the text (the text field is replaced with it). media_error / media_skipped (e.g. "too large (…MB)", 25 MB cap) means no file — say so rather than inventing content.
    • A remoteJid ending @g.us is a group; @s.whatsapp.net is a 1:1 (the digits before @ are the number). archived: null on a message means that chat isn't in the index yet — run list-chats.mjs once to refresh it.
    • pendingNotifications= in the READ_OK line is the field to read before you say anything about how many messages there are. complete = WhatsApp confirmed the queue is empty; timeout = the reader gave up while messages were still coming, so any count from that run is a floor, not a total. Raise WA_PENDING_TIMEOUT_MS (ms, default 60000) and run again.
    • If a big flush is expected (right after pairing, or after the user was offline for a while), give it room: WA_PENDING_TIMEOUT_MS=300000 node scripts/whatsapp/personal/read.mjs ….

Catching up after a long gap. If the linked device hasn't connected for days or weeks, the backlog arrives as a long series of append batches, and one run may not exhaust it. The recipe that recovered 6,687 messages (a month of backlog) on 2026-08-24:

WA_PENDING_TIMEOUT_MS=300000 node scripts/whatsapp/personal/read.mjs \
  --include-archived --since 24 --history-days 45 --limit 1000 --skip-media
  • --history-days must cover the age of the backlog, not the window you want to report on. The store prunes to that many days on every write, so leaving it at 7 while a month-old queue is arriving deletes each batch as it lands — the run looks successful and the file stays empty.
  • --skip-media for the catch-up passes. One attachment failing mid-flush used to end the whole process; skip the downloads while draining the queue, then do a normal read afterwards to fetch media for what you kept.
  • Repeat while the line says pendingNotifications=timeout. Each pass picks up where the last one stopped, because every batch is written to messages.json as it arrives.
  • Report progress from the store, not from one run's countmessages.json is the total, last-read.json is only the filtered view of the last read.
  • Send: write the exact message to scripts/whatsapp/msg.txt with your file-writing tool (not the shell — that keeps quotes, apostrophes, $, and newlines intact), then run node scripts/whatsapp/personal/send.mjs <number> scripts/whatsapp/msg.txt. For a group, pass the full <id>@g.us jid instead of the number. Confirm the exact wording first for anything with a price, date, or commitment.

When something breaks

  • The phone says the code is wrong / "couldn't link device", and the log shows pairing failed, status 408 → the socket announced a browser name WhatsApp won't register. browser must be a real browser, e.g. Browsers.ubuntu('Chrome'). A non-browser label such as 'Desktop' is accepted when reconnecting an already-linked device but refused at registration, so an existing link keeps working while every new one fails. requestPairingCode is client-side, so a code is always produced — the refusal only ever shows up on the phone. QR fails the same way, which is the giveaway that it isn't code-specific.
  • CLOSED_405 on every read/send, right after a pairing that succeeded → the socket didn't pass version. baileys' built-in WA-Web version goes stale and WhatsApp closes the connection. Pass await waVersion() into every makeWASocket — not just the one in pair.mjs. This is not an expired session: auth/ is intact and re-pairing won't help.
  • LOGGED_OUT on every command → the link really was removed from the phone. Re-run pairing.
  • CONNECT_TIMEOUT or a slow first command after a cold container is not an expired session — reconnect + sync just takes longer. Wait for the process to exit, then try again once. Never launch a second command while one is still running, and never tell the user the session expired unless the output literally says LOGGED_OUT or device_removed.
  • CLOSED_515 / "restart required" right after pairing is normal — the scripts above reconnect on their own. Don't treat it as a failure and don't re-pair.
  • conflict / device_removed, or the phone shows "couldn't link device" → two connections ran at once, or you re-linked too soon after a failure. WhatsApp puts the account into a short cooldown on new linked devices — this is not a ban. Wait 30–60 minutes, on the phone remove any stale "Ubuntu / Chrome" entry under Linked Devices, then pair once.
  • READ_OK count=0 newThisRun=0 on a working connection → read pendingNotifications= before concluding anything. complete means WhatsApp really had nothing queued for this device: widen with --since / --include-archived if you expected more. timeout means the opposite — the offline queue was still running when the reader stopped, and the zero is an artefact of stopping early. Raise WA_PENDING_TIMEOUT_MS and run again. Never tell the user they have no messages off a timeout run; that exact mistake reported an empty WhatsApp to a user who had 6,687 undelivered messages waiting.
  • READ_OK … newThisRun=487 (or any large number) but count=0 and messages.json barely grew → the prune window is narrower than the age of what arrived. --history-days sets both the fetch window and the retention window; raise it to cover the backlog.
  • archiveUnknown is high / archived: null on messages → the chat index is stale. Run node scripts/whatsapp/personal/list-chats.mjs (it does an app-state resync) to populate names + archive flags into chats.json. Note: the archived-skip only covers chats the index knows about; a message whose chat is unknown is shown (dropping unknowns would hide normal chats on a fresh setup). When archiveUnknown > 0, tell the user the archive filter was incomplete for that many messages — don't imply archived chats were fully excluded.
  • resync=failed:Connection ClosedresyncAppState was called too early. It must run after connect() resolves (after open), never inside onSocket.
  • count=0 while the connection opens fine and you just sent messages → your own sent messages aren't re-flushed; that's expected. Don't read results from stdout — baileys logs there; use last-read.json.
  • history={"chats":0,…} on --sync-history → expected on an already-linked session: WhatsApp only hands over old history right after a fresh link, so on-demand pagination has no anchor. This is not a bug — old history that wasn't captured at pairing is gone. Tell the user so instead of retrying. This says nothing about missed messages: anything sent while the device was disconnected still arrives through the offline queue, so don't quote this line as proof that a quiet period is empty.
  • media_error on a media row → usually the media aged off WhatsApp's servers, or the node arrived without a mimetype. New arrivals download fine; a very old [audio]/[image] captured before this version has no key and can't be recovered. media_skipped "too large" is the 25 MB cap, not a failure.
  • A voice note has no transcript (only transcription_error) → check faster-whisper is installed (python3 -c "import faster_whisper"); the first run downloads the model (~140 MB) and can take a minute. It needs no ffmpeg.
  • READ_OK … newThisRun=0 right after the user sent something → the send hadn't reached the linked device yet, or the reader stopped first. Have them wait until it shows in their chat, then read again (bump WA_PENDING_TIMEOUT_MS). Don't launch a second read while one is running.
  • Cannot find package 'baileys' → the scripts were moved away from scripts/whatsapp/node_modules. Keep them under scripts/whatsapp/ (Node resolves node_modules upward from each file).
  • Nothing appears under Integrations → give it ~30s (the sidebar caches), then reload. Files must sit under scripts/<provider>/<service>/ or carry a @plank-integration header.

See also: Connecting WhatsApp Business for the sanctioned customer-line setup, Workspace scripts for the sidebar convention, and Automations if you later want a scheduled morning read.