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 toscripts/whatsapp/messages.jsonandlast-read.jsonin 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.mjsgrabs 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_modulestogether underscripts/whatsapp/. Node resolvesnode_modulesby walking up from each script file, sopersonal/read.mjsfindsscripts/whatsapp/node_modules. Move the scripts elsewhere andbaileysstops 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), printsREAD_OK count=N pendingNotifications=complete|timeout …to stderr, and writes the result toscripts/whatsapp/last-read.json. Open that file, then summarize by contact. Messages accumulate inmessages.json(last 7 days), so a repeat read the same day still shows earlier messages.- Archived chats are skipped by default. Add
--include-archivedto include them. - Other flags:
--only <name-or-jid>(one contact/group),--since <hours>(default 24),--limit <n>.--sync-history --history-days 7attempts 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_pathhas a real file underscripts/whatsapp/media/. View images with your file tools; read documents; for voice notes thetranscriptfield already holds the text (thetextfield 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
remoteJidending@g.usis a group;@s.whatsapp.netis a 1:1 (the digits before@are the number).archived: nullon a message means that chat isn't in the index yet — runlist-chats.mjsonce to refresh it. pendingNotifications=in theREAD_OKline 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. RaiseWA_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 ….
- Archived chats are skipped by default. Add
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-daysmust 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-mediafor 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 tomessages.jsonas it arrives. - Report progress from the store, not from one run's
count—messages.jsonis the total,last-read.jsonis only the filtered view of the last read. - Send: write the exact message to
scripts/whatsapp/msg.txtwith your file-writing tool (not the shell — that keeps quotes, apostrophes,$, and newlines intact), then runnode scripts/whatsapp/personal/send.mjs <number> scripts/whatsapp/msg.txt. For a group, pass the full<id>@g.usjid 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.browsermust 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.requestPairingCodeis 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_405on every read/send, right after a pairing that succeeded → the socket didn't passversion. baileys' built-in WA-Web version goes stale and WhatsApp closes the connection. Passawait waVersion()into everymakeWASocket— not just the one inpair.mjs. This is not an expired session:auth/is intact and re-pairing won't help.LOGGED_OUTon every command → the link really was removed from the phone. Re-run pairing.CONNECT_TIMEOUTor 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 saysLOGGED_OUTordevice_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=0on a working connection → readpendingNotifications=before concluding anything.completemeans WhatsApp really had nothing queued for this device: widen with--since/--include-archivedif you expected more.timeoutmeans the opposite — the offline queue was still running when the reader stopped, and the zero is an artefact of stopping early. RaiseWA_PENDING_TIMEOUT_MSand run again. Never tell the user they have no messages off atimeoutrun; 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) butcount=0andmessages.jsonbarely grew → the prune window is narrower than the age of what arrived.--history-dayssets both the fetch window and the retention window; raise it to cover the backlog.archiveUnknownis high /archived: nullon messages → the chat index is stale. Runnode scripts/whatsapp/personal/list-chats.mjs(it does an app-state resync) to populate names + archive flags intochats.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). WhenarchiveUnknown> 0, tell the user the archive filter was incomplete for that many messages — don't imply archived chats were fully excluded.resync=failed:Connection Closed→resyncAppStatewas called too early. It must run afterconnect()resolves (afteropen), never insideonSocket.count=0while 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; uselast-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_erroron 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(onlytranscription_error) → checkfaster-whisperis 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=0right 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 (bumpWA_PENDING_TIMEOUT_MS). Don't launch a second read while one is running.Cannot find package 'baileys'→ the scripts were moved away fromscripts/whatsapp/node_modules. Keep them underscripts/whatsapp/(Node resolvesnode_modulesupward 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-integrationheader.
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.