Plank help · updated 2026-08-24
Подключение WhatsApp (личный номер)
Продвинуто и на свой риск: ассистент работает с вашим личным WhatsApp по запросу — читает сообщения (архивные чаты по умолчанию пропускаются) и отправляет ответы только когда вы просите, без постоянного слушателя. Нарушает правила WhatsApp; только для личного рабочего пространства.
Agents: fetch the raw markdown of this page at /ru/help/connecting-whatsapp-personal.md
Подключение WhatsApp (личный номер)
Это подключает ваш собственный личный аккаунт WhatsApp, чтобы ассистент мог читать ваши чаты и отправлять сообщения — только когда вы просите («прочитай мой WhatsApp», «напиши Ерлану, что цена 40 000»). Никакого слушателя и опроса нет: ассистент подключается, делает одно действие и отключается.
Это другая функция, чем Подключение WhatsApp Business. Там — официальный клиентский API от Meta. Здесь — работа с вашим личным номером через open-source библиотеку baileys. Она существует для одного: держать ваш собственный день в контексте — расценки, договорённости о встречах, переписку по поддержке, — а не для публичной клиентской линии.
Ассистенту — сначала проверьте, что вы на нужной странице. Если пользователю нужна клиентская линия / поддержка / продажи или подключить номер, на который будут писать другие люди, — это не та страница: используйте connecting-whatsapp (официальный путь без риска бана). Продолжайте здесь, только если речь именно о его собственном личном номере, который он читает/с которого отправляет по запросу.
Прочитайте это, прежде чем включать
1. Это нарушает правила WhatsApp. Автоматизация личного аккаунта может привести к бану номера — а бан извинениями не исправить. Держите использование человеческим: не рассылайте массово, не пишите тем, кто вам не писал, не отвечайте с машинной скоростью круглые сутки. Это безопасно потому что объём мал и вы в контуре принятия решений, а не потому что Meta это разрешает. Не разрешает.
2. Логин хранится в виде обычных файлов в вашем рабочем пространстве. При привязке создаётся папка
scripts/whatsapp/auth/— это учётные данные на предъявителя: любой, кто может прочитать файлы вашего пространства, может перехватить вашу сессию WhatsApp. Скрипты чтения/отправки также пишут текст ваших недавних сообщений вscripts/whatsapp/messages.jsonиlast-read.jsonв открытом виде. Используйте это только в своём личном рабочем пространстве. Никогда в общем или командном, и никогда как способ подключать клиентов. Нужна публичная линия — используйте WhatsApp Business.
3. Точка подтверждения — вы. Ассистент отправляет только ровно то сообщение, которое вы попросили. Он не должен выдумывать цену, дату или обязательство. Если указание расплывчато («скажи ему цену»), он сначала спросит у вас точный текст.
Если что-то из этого неприемлемо — остановитесь и используйте путь Business.
Что это умеет и что нет
- Отправка — надёжно. Одно сообщение контакту или в группу, которое вы попросили.
- Чтение — по возможности, и архивные чаты по умолчанию пропускаются. Сразу после подключения WhatsApp отдаёт кусок недавней истории; ассистент собирает несколько секунд и делает выжимку. Тонкий кусок означает «частичная синхронизация», а не «сообщений нет».
- Вложения — голосовые, изображения, видео и документы скачиваются и расшифровываются в
scripts/whatsapp/media/, чтобы ассистент мог посмотреть изображение, прочитать документ или расшифровать голосовое. Голосовые распознаются локально (faster-whisper, язык определяется автоматически). Файлы больше 25 МБ помечаются, но не скачиваются. - Неделя истории — доступна, но только если захватить её при привязке. Полный архив (примерно до недели) WhatsApp отдаёт ровно один раз, в секунды сразу после привязки устройства, поэтому
pair.mjsзабирает его тогда. На уже привязанной сессии задним числом получить старую историю нельзя — WhatsApp её повторно не отдаёт. Дальше новые сообщения накапливаются с этого момента. - Группы — здесь видны (в отличие от Business API), потому что это ваш реальный аккаунт.
- Держать контекст самостоятельно — не в этой версии «по запросу». Ассистент знает, что в WhatsApp, только когда вы просите посмотреть. Когда этого не хватит — это одна запланированная задача, вызывающая скрипт чтения каждое утро, без новой настройки.
Настройка — вы просто просите ассистента
Вам не нужно запускать это вручную. Напишите в чате: «подключи мой личный WhatsApp». Ассистент загрузит эту страницу, установит библиотеку и проведёт вас через разовую привязку. Ваш единственный ручной шаг — несколько нажатий на телефоне. Остальная часть страницы — рецепт, которому следует ассистент.
Для ассистента — рецепт
Всё лежит в scripts/whatsapp/ по соглашению рабочих скриптов, поэтому инструменты попадают в scripts/<provider>/<service>/… и автоматически появляются в разделе Интеграции → WhatsApp в боковой панели:
scripts/whatsapp/
package.json
node_modules/ <- ставится один раз; сканер и файловый watcher его пропускают
auth/ <- учётные данные привязанного устройства (секрет на предъявителя)
lib/wa.mjs <- общий helper подключения + скачивание медиа + хранилища на диске
lib/transcribe-audio.py<- локальная расшифровка голосовых (faster-whisper)
personal/pair.mjs <- разовая привязка + захват недельной истории
personal/read.mjs <- чтение (пишет last-read.json + messages.json, скачивает медиа)
personal/send.mjs <- отправка
personal/list-chats.mjs<- обновить индекс чатов/архива (chats.json)
chats.json <- кэш индекса чатов: имя + архив + непрочитанные (генерируется)
messages.json <- скользящий 7-дневный лог сообщений с ключами + ссылками на медиа (генерируется)
last-read.json <- отфильтрованный вывод последнего чтения (генерируется)
media/ <- скачанные голосовые / изображения / видео / документы (генерируется)
Правила, которые нельзя нарушать:
- Держите скрипты и
node_modulesвместе вscripts/whatsapp/. Node ищетnode_modules, поднимаясь вверх от файла скрипта, поэтомуpersonal/read.mjsнаходитscripts/whatsapp/node_modules. Перенесите скрипты в другое место — иbaileysперестанет находиться. - Никогда не завязывайтесь на текущий каталог. Каждый скрипт вычисляет свои пути от собственного файла через
import.meta.url, поэтому работает откуда угодно. - По одному скрипту за раз. Никогда не запускайте два WhatsApp-скрипта одновременно — два живых подключения с одним логином заставят WhatsApp сбросить привязанное устройство (
conflict / device_removed).
Устанавливается один раз и переживает перезапуск контейнера.
0. Дайте предупреждение о рисках и получите явное согласие (обязательно — сделайте это первым)
Прежде чем что-либо устанавливать, привязывать или подключать, отправьте пользователю это уведомление на его языке и дождитесь чёткого «да». Не запускайте ни одной команды, пока он не подтвердит.
⚠️ Предупреждение перед настройкой: подключение вашего личного WhatsApp таким способом неофициально и нарушает правила WhatsApp. Это может привести к временному или постоянному бану номера, а ваш логин WhatsApp (и текст недавних сообщений) будет храниться в виде файлов в этом рабочем пространстве. Я буду держать малый объём и отправлять только то, что вы попросите. Продолжить на ваш собственный риск?
Если пользователь колеблется или отказывается — остановитесь и направьте его на путь Business. Только при явном «да» переходите к шагу 1.
1. Установка (один раз)
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; }
# расшифровка голосовых (локально, без API-ключа). Пропустите, если аудио не нужно.
python3 -c "import faster_whisper" 2>/dev/null || pip install --user faster-whisper
Для текстовых сообщений нативные модули не нужны — крипта baileys идёт как переносимый WASM, а sharp/jimp (только для медиа) — опциональные peer-зависимости, их можно игнорировать. Для голосовых faster-whisper распознаёт речь локально — ему не нужен бинарник ffmpeg (Opus декодируется встроенным PyAV) и не нужен API-ключ; модель (~140 МБ, base) скачивается один раз в ~/.cache и, как node_modules, лежит на постоянном домашнем томе, поэтому переживает пересоздание контейнера. Задайте WHISPER_MODEL (например small) для большей точности или WHISPER_LANGUAGE, чтобы зафиксировать один язык.
2. Создайте helper и скрипты (один раз)
Заголовки @plank-integration необязательны — всё, что лежит в scripts/<provider>/<service>/, определяется по самому пути, — но оставьте их: они дают боковой панели нормальное название и описание вместо голого имени файла. Код скриптов ниже идентичен английской версии (комментарии на английском).
scripts/whatsapp/lib/wa.mjs — общее подключение + скачивание медиа + хранилища на диске. Helper подключается в режиме полной истории и всегда объявляет актуальную версию WhatsApp Web (устаревшая версия закрывается с 405), держит кэш getMessage из сохранённого protobuf (нужен для расшифровки медиа и пагинации истории), а хранилище теперь сохраняет ключ + protobuf каждого сообщения, чтобы медиа можно было скачать задним числом:
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 — привязка аккаунта (запустить один раз, навсегда). Привязывается в режиме полной истории (браузер — Browsers.ubuntu('Chrome'): имя должно быть настоящим браузером, иначе регистрация отклоняется) и захватывает начальный дамп — примерно до недели чатов и сообщений, которые WhatsApp отдаёт только в секунды сразу после свежей привязки, — в messages.json + chats.json. Это единственный шанс получить старую историю; последующее чтение запросить её уже не сможет:
// @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 — прочитать недавние сообщения и скачать их медиа (голосовые → расшифровка, изображения/видео/документы → в media/, слишком большие пропускаются). Также дозагружает медиа для старых сохранённых записей, попавших в хранилище без файлов (например, при привязке). По умолчанию пропускает архивные чаты; принимает --include-archived, --only <name|jid>, --since <hours> (по умолчанию 24), --limit <n>, --sync-history, --history-days <n>. Окно ожидания — 18с (задайте WA_SETTLE_MS, чтобы ждать дольше при большом дампе):
// @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 — отправить ровно то сообщение, что попросил пользователь. Он читает текст из файла, а не из командной строки, поэтому кавычки, апострофы, $ и переносы строк в тексте не могут поломать команду оболочки или что-то внедрить:
// @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 — обновить индекс чатов/архива. Запустите один раз после привязки (и время от времени потом), чтобы read.mjs знал, какие чаты в архиве:
// @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 — локальная расшифровка голосовых (вызывается из read.mjs; без API-ключа, без 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. Привязка (один раз, вместе с пользователем) — именно тогда скачивается история
Из корня рабочего пространства запустите node scripts/whatsapp/personal/pair.mjs <номер пользователя> и покажите пользователю PAIRING_CODE. Скажите: откройте WhatsApp на телефоне → Настройки → Связанные устройства → Привязка устройства → Привязать по номеру телефона, и введите код. После этого привязка удерживает соединение ради начального дампа истории — 20-секундный таймер тишины запускается только когда история реально начала приходить, поэтому скрипт ждёт, а не выходит пустым, — и печатает HISTORY captured=N …, а затем LINKED. Папка auth/ сохранена, и привязывать снова не нужно.
Это единственный момент, когда можно скачать неделю прошлой истории. WhatsApp отдаёт архив ровно один раз, сразу после свежей привязки; получить его задним числом на уже привязанной сессии нельзя. Поэтому не пропускайте этот шаг — если пользователю нужен разбор его недели, историю надо захватить здесь.
Это дамп истории, и это не то же самое, что пропущенные сообщения. Всё, что прислали пользователю уже после привязки, копится в очереди для этого устройства, пока оно отключено, и приходит при следующем подключении — см. «Догрузка после долгого перерыва» ниже. Никогда не говорите пользователю, что его сообщения пропали, на том основании, что дамп истории нельзя запросить повторно: это два разных механизма с противоположными ответами.
Вводите код быстро — он истекает. CLOSED_515 / «требуется перезапуск» в процессе — это нормально: скрипт переподключается сам и затем печатает LINKED. Сразу после успешной привязки один раз запустите node scripts/whatsapp/personal/list-chats.mjs, чтобы наполнить индекс чатов (имена + какие чаты в архиве), затем один раз node scripts/whatsapp/personal/read.mjs, чтобы скачать и расшифровать медиа из захваченной истории.
4. Чтение / отправка (по запросу)
Только по одному действию за раз. Никогда не запускайте чтение и отправку — или два чтения — одновременно. Два живых подключения с одним логином заставляют WhatsApp сбросить привязанное устройство (conflict / device_removed) и требуют повторной привязки. Завершите одну команду, прежде чем начинать следующую. Обычное чтение работает ~15–25 секунд и молчит, пока выполняется, — это нормально, а не мёртвая сессия; после долгого перерыва оно держит соединение столько, сколько offline-очередь продолжает отдавать сообщения.
- Чтение:
node scripts/whatsapp/personal/read.mjs— ждёт, пока WhatsApp сообщит, что offline-очередь исчерпана (обычно ~20 секунд, после перерыва — минуты), печатаетREAD_OK count=N pendingNotifications=complete|timeout …в stderr и записывает результат вscripts/whatsapp/last-read.json. Откройте этот файл и сделайте выжимку по контактам. Сообщения накапливаются вmessages.json(последние 7 дней), поэтому повторное чтение в тот же день всё ещё покажет более ранние сообщения.- Архивные чаты по умолчанию пропускаются. Добавьте
--include-archived, чтобы включить их. - Другие флаги:
--only <name-or-jid>(один контакт/группа),--since <hours>(по умолчанию 24),--limit <n>.--sync-history --history-days 7пытается постранично догрузить историю личных чатов (по возможности; см. примечание про привязку — это не замена захвату при привязке). - Медиа — у каждой записи с
media_pathесть реальный файл вscripts/whatsapp/media/. Изображения открывайте своими файловыми инструментами; документы читайте; для голосовых полеtranscriptуже содержит текст (полеtextзаменяется на него).media_error/media_skipped(например «too large (…MB)», лимит 25 МБ) означает, что файла нет — так и скажите, а не выдумывайте содержимое. remoteJid, оканчивающийся на@g.us, — группа;@s.whatsapp.net— диалог один на один (цифры до@— это номер).archived: nullу сообщения означает, что этого чата ещё нет в индексе — запуститеlist-chats.mjs, чтобы обновить его.pendingNotifications=в строкеREAD_OK— то поле, которое надо прочитать, прежде чем говорить хоть что-то о количестве сообщений.complete— WhatsApp подтвердил, что очередь пуста;timeout— читатель остановился, пока сообщения ещё шли, и любое число из такого прогона — это нижняя граница, а не итог. ПоднимитеWA_PENDING_TIMEOUT_MS(мс, по умолчанию 60000) и запустите снова.- Если ожидается большой поток (сразу после привязки или после того, как пользователь долго был офлайн), дайте запас:
WA_PENDING_TIMEOUT_MS=300000 node scripts/whatsapp/personal/read.mjs ….
- Архивные чаты по умолчанию пропускаются. Добавьте
Догрузка после долгого перерыва. Если привязанное устройство не подключалось несколько дней или недель, накопленное приходит длинной серией порций append, и одного прогона может не хватить. Рецепт, которым 2026-08-24 было восстановлено 6 687 сообщений (месяц накопленного):
WA_PENDING_TIMEOUT_MS=300000 node scripts/whatsapp/personal/read.mjs \
--include-archived --since 24 --history-days 45 --limit 1000 --skip-media
--history-daysдолжен покрывать возраст накопленного, а не то окно, о котором вы хотите отчитаться. Хранилище обрезается до этого числа дней при каждой записи, поэтому значение 7 при месячной очереди удаляет каждую порцию ровно в тот момент, когда она пришла: прогон выглядит успешным, а файл остаётся пустым.--skip-media— для проходов догрузки. Раньше одно упавшее вложение обрывало весь процесс; на время вычерпывания очереди пропускайте скачивание, а после сделайте обычное чтение, чтобы забрать медиа к тому, что сохранилось.- Повторяйте, пока в строке стоит
pendingNotifications=timeout. Каждый проход продолжает с того места, где остановился предыдущий, потому что каждая порция пишется вmessages.jsonсразу при получении. - Отчитывайтесь по хранилищу, а не по
countодного прогона:messages.json— это итог,last-read.json— только отфильтрованный вид последнего чтения. - Отправка: запишите точный текст в
scripts/whatsapp/msg.txtсвоим инструментом записи файлов (не через оболочку — так сохранятся кавычки, апострофы,$и переносы строк), затем запуститеnode scripts/whatsapp/personal/send.mjs <номер> scripts/whatsapp/msg.txt. Для группы передайте вместо номера полный jid<id>@g.us. Сначала подтвердите точную формулировку для всего, что содержит цену, дату или обязательство.
Если что-то ломается
- Телефон пишет, что код неверный / «не удалось привязать устройство», а в логе
pairing failed, status 408→ сокет объявил имя браузера, которое WhatsApp не регистрирует. Вbrowserдолжен быть настоящий браузер, напримерBrowsers.ubuntu('Chrome'). Метка вроде'Desktop'принимается при переподключении уже привязанного устройства, но отклоняется при регистрации — поэтому старая привязка продолжает работать, а все новые падают.requestPairingCodeвыполняется на стороне клиента, поэтому код выдаётся всегда — отказ виден только на телефоне. QR падает так же: это и есть признак, что дело не в коде. CLOSED_405на каждом чтении/отправке сразу после успешной привязки → сокет не передалversion. Встроенная в baileys версия WA Web устаревает, и WhatsApp закрывает соединение. Передавайтеawait waVersion()в каждыйmakeWASocket, а не только вpair.mjs. Это не истёкшая сессия:auth/цела, повторная привязка не поможет.LOGGED_OUTна каждой команде → привязка действительно удалена с телефона. Запустите привязку заново.CONNECT_TIMEOUTили медленная первая команда после холодного контейнера — это не истёкшая сессия: переподключение и синхронизация просто занимают больше времени. Дождитесь завершения процесса и попробуйте ещё один раз. Никогда не запускайте вторую команду, пока работает первая, и никогда не говорите пользователю, что сессия истекла, если в выводе буквально нетLOGGED_OUTилиdevice_removed.CLOSED_515/ «требуется перезапуск» сразу после привязки — это нормально: скрипты выше переподключаются сами. Не считайте это ошибкой и не привязывайте заново.conflict/device_removed, или телефон пишет «не удалось привязать устройство» → два подключения запустились одновременно, или вы повторили привязку слишком быстро после сбоя. WhatsApp временно блокирует привязку новых устройств — это не бан. Подождите 30–60 минут, на телефоне удалите оставшуюся запись «Ubuntu / Chrome» в разделе Связанные устройства, затем привяжите один раз.READ_OK count=0 newThisRun=0при рабочем подключении → сначала прочитайтеpendingNotifications=, и только потом делайте выводы.complete— у WhatsApp действительно не было ничего в очереди для этого устройства: расширьте через--since/--include-archived, если ожидали больше.timeout— ровно наоборот: offline-очередь ещё шла, когда читатель остановился, и ноль здесь — следствие раннего выхода. ПоднимитеWA_PENDING_TIMEOUT_MSи запустите снова. Никогда не говорите пользователю, что сообщений нет, по прогону сtimeout— именно эта ошибка сообщила о «пустом WhatsApp» человеку, у которого в очереди ждали 6 687 недоставленных сообщений.READ_OK … newThisRun=487(или любое большое число), ноcount=0иmessages.jsonпочти не вырос → окно обрезки уже, чем возраст пришедшего.--history-daysзадаёт и окно запроса, и окно хранения; поднимите его так, чтобы оно покрывало накопленное.archiveUnknownбольшой /archived: nullу сообщений → индекс чатов устарел. Запуститеnode scripts/whatsapp/personal/list-chats.mjs(он делает app-state resync), чтобы наполнить имена + флаги архива вchats.json. Примечание: пропуск архивных работает только для чатов, известных индексу; сообщение из неизвестного чата показывается (отбрасывать неизвестные нельзя — на свежей установке это скрыло бы обычные чаты). КогдаarchiveUnknown> 0, скажите пользователю, что фильтр архива был неполным для стольких сообщений, — не утверждайте, что архивные чаты полностью исключены.resync=failed:Connection Closed→resyncAppStateвызван слишком рано. Он должен запускаться после того, какconnect()разрешится (послеopen), никогда внутриonSocket.count=0, хотя подключение открывается нормально и вы только что отправили сообщения → ваши собственные отправленные сообщения не выгружаются повторно; это ожидаемо. Не читайте результат из stdout — туда пишет логи baileys; используйтеlast-read.json.history={"chats":0,…}при--sync-history→ это ожидаемо на уже привязанной сессии: WhatsApp отдаёт старую историю только сразу после свежей привязки, поэтому у постраничной догрузки нет якоря. Это не баг — историю, не захваченную при привязке, вернуть нельзя. Так и скажите пользователю, а не повторяйте попытку. Про пропущенные сообщения эта строка не говорит ничего: всё, что прислали, пока устройство было отключено, всё равно придёт через offline-очередь, — не приводите её как доказательство, что тихий период пуст.media_errorу медиа-записи → обычно медиа устарело на серверах WhatsApp или узел пришёл без mimetype. Новые сообщения скачиваются нормально; очень старое[audio]/[image], попавшее в хранилище до этой версии, не имеет ключа и не восстанавливается.media_skipped«too large» — это лимит 25 МБ, а не сбой.- У голосового нет
transcript(толькоtranscription_error) → проверьте, чтоfaster-whisperустановлен (python3 -c "import faster_whisper"); первый запуск скачивает модель (~140 МБ) и может занять минуту. ffmpeg не нужен. READ_OK … newThisRun=0сразу после того, как пользователь что-то отправил → отправленное ещё не дошло до привязанного устройства, или читатель остановился раньше. Пусть подождёт, пока сообщение появится в его чате, и прочитайте снова (увеличьтеWA_PENDING_TIMEOUT_MS). Не запускайте второе чтение, пока работает первое.Cannot find package 'baileys'→ скрипты унесли отscripts/whatsapp/node_modules. Держите их вscripts/whatsapp/(Node ищетnode_modules, поднимаясь вверх от каждого файла).- В Интеграциях ничего не появилось → подождите ~30 секунд (боковая панель кэширует) и перезагрузите. Файлы должны лежать в
scripts/<provider>/<service>/или нести заголовок@plank-integration.
См. также: Подключение WhatsApp Business для официальной настройки клиентской линии, Рабочие скрипты про соглашение боковой панели и Автоматизации, если позже захотите запланированное утреннее чтение.