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 ClosedresyncAppState вызван слишком рано. Он должен запускаться после того, как 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 для официальной настройки клиентской линии, Рабочие скрипты про соглашение боковой панели и Автоматизации, если позже захотите запланированное утреннее чтение.