1. Какую проблему решаем?
Большинство готовых интеграций WhatsApp с CRM-системами или корпоративными чатами требуют ежемесячной подписки за каждый подключенный номер (от 1500 до 5000 рублей в месяц). При этом бизнес часто хочет аккумулировать сообщения из разных источников в одном месте — например, в группе ВКонтакте, где менеджеры уже привыкли обрабатывать входящие лиды.
Данный проект решает эту задачу полностью бесплатно. Мы создаем независимый мост (Relay), который пересылает сообщения клиентов из WhatsApp в вашу группу ВК. Менеджер отвечает на сообщение в ВК с помощью стандартной функции «Ответить» (Reply), а скрипт автоматически отправляет сообщение обратно конкретному клиенту в WhatsApp.
2. Возможности решения:
-
Двухсторонний обмен: Клиент пишет в WhatsApp ↔ Менеджер отвечает из интерфейса ВК.
-
Поддержка медиафайлов: Корректная пересылка изображений, документов, голосовых сообщений (в WA уходят как PTT/Voice, в ВК приходят как аудиосообщения) и стикеров.
-
Умное определение отправителя: Скрипт парсит
pushName(реальное имя пользователя из настроек мессенджера) и отображает его рядом с техническим ID (включая работу со специфическими бизнес-аккаунтами и companion-устройствами@lid). -
Интерактивное управление из ВК через команды:
-
/help— вывод интерактивной справки по доступным командам. -
/pause— временная приостановка трансляции всех сообщений (режим «выходной день»). -
/resume— возобновление работы моста. -
/block_forwarded— автоматическая фильтрация и бан спам-рассылок (сообщений с пометкой «Пересланное» в WA). -
/block— блокировка конкретного номера (можно переслать сообщение спамера с командой или указать ID вручную). -
/unblock— удаление из черного списка.
-
-
Self-hosted архитектура: Конфигурация (
config.json) и черный список (exclude.json) хранятся локально. Сессия авторизации кэшируется — QR-код сканируется только один раз.
3. Архитектура и стек технологий
-
Node.js — среда выполнения.
-
@whiskeysockets/baileys — современная и активно обновляемая библиотека для работы с WhatsApp Web протоколом (с автоматическим маскированием под актуальные версии браузеров для защиты от банов).
-
vk-io — мощный фреймворк для работы с API ВКонтакте через LongPoll.
-
PM2 — менеджер процессов для Linux, обеспечивающий отказоустойчивость, логирование и автоматический перезапуск моста при сбоях сети или перезагрузке VDS.
Техническая реализация (Исходный код relay.js)
Ниже представлен полный листинг скрипта, защищенный от утечек памяти (обработчики событий LongPoll инициализируются строго один раз при старте, исключая дублирование сообщений при переподключениях сокета WhatsApp).
Исходный код relay.js
const {
default: makeWASocket,
useMultiFileAuthState,
DisconnectReason,
Browsers,
fetchLatestBaileysVersion,
downloadMediaMessage
} = require("@whiskeysockets/baileys");
const { VK } = require("vk-io");
const { Boom } = require("@hapi/boom");
const qrcode = require("qrcode-terminal");
const pino = require("pino");
const fs = require("fs");
const readline = require("readline");
const CONFIG_FILE = "./config.json";
const EXCLUDE_FILE = "./exclude.json";
// Глобальные переменные для правильной работы моста
let vk = null;
let globalSock = null;
let config = null;
const askQuestion = (query) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise(resolve => rl.question(query, ans => { rl.close(); resolve(ans.trim()); }));
};
async function initConfig() {
let cfg = {};
if (fs.existsSync(CONFIG_FILE)) {
cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
} else {
console.log("\n=== ПЕРВОНАЧАЛЬНАЯ НАСТРОЙКА МОСТА ===");
cfg.VK_TOKEN = await askQuestion("Введите VK_TOKEN (токен группы ВК): ");
cfg.VK_GROUP_ID = Number(await askQuestion("Введите VK_GROUP_ID (ID группы, только цифры): "));
cfg.MY_VK_ID = Number(await askQuestion("Введите MY_VK_ID (Ваш личный ID в ВК, куда слать сообщения): "));
cfg.MASTER_WA_PHONE = await askQuestion("Введите MASTER_WA_PHONE (Ваш номер WhatsApp, только цифры без +): ");
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 4));
console.log("c89; Настройки успешно сохранены в config.json!\n");
}
return cfg;
}
function getExclusions() {
if (fs.existsSync(EXCLUDE_FILE)) {
return JSON.parse(fs.readFileSync(EXCLUDE_FILE, "utf8"));
} else {
const defaultExclusions = ["status@broadcast"];
fs.writeFileSync(EXCLUDE_FILE, JSON.stringify(defaultExclusions, null, 4));
return defaultExclusions;
}
}
function saveExclusions(exclusions) {
fs.writeFileSync(EXCLUDE_FILE, JSON.stringify(exclusions, null, 4));
}
// === ИНИЦИАЛИЗАЦИЯ ВКОНТАКТЕ ===
async function initVK() {
vk = new VK({ token: config.VK_TOKEN, pollingGroupId: config.VK_GROUP_ID });
vk.updates.on('message_new', async (context) => {
if (context.isOutbox) return;
// --- БЛОК ОБРАБОТКИ СЛУЖЕБНЫХ КОМАНД ---
if (context.text) {
const textCommand = context.text.trim();
// Справка по командам
if (textCommand === '/help') {
const helpText = `🤖 Справка по командам моста:\n\n` +
`🔹 /help — Показать это сообщение\n` +
`🔹 /pause — Остановить пересылку сообщений из WA в ВК (глобальная пауза)\n` +
`🔹 /resume — Возобновить пересылку сообщений\n` +
`🔹 /block_forwarded — Игнорировать чужой пересланный спам\n` +
`🔹 /block — Внести в черный список (ответьте на сообщение или напишите ID, например: /block 12345)\n` +
`🔹 /unblock — Достать из черного списка (аналогично команде /block)`;
await context.send(helpText);
return;
}
if (textCommand === '/pause') {
let exclusions = getExclusions();
if (!exclusions.includes("ALL_MESSAGES")) {
exclusions.push("ALL_MESSAGES");
saveExclusions(exclusions);
await context.send(`\08;A039; ПАУЗА: Пересылка всех входящих сообщений из WhatsApp приостановлена.`);
} else {
await context.send(`b88;A039; Мост уже находится на паузе.`);
}
return;
}
if (textCommand === '/resume') {
let exclusions = getExclusions();
if (exclusions.includes("ALL_MESSAGES")) {
exclusions = exclusions.filter(id => id !== "ALL_MESSAGES");
saveExclusions(exclusions);
await context.send(``54;A039; СТАРТ: Пересылка сообщений из WhatsApp возобновлена.`);
} else {
await context.send(`b88;A039; Мост и так работает.`);
}
return;
}
if (textCommand === '/block_forwarded') {
let exclusions = getExclusions();
if (!exclusions.includes("FORWARDED_MESSAGES")) {
exclusions.push("FORWARDED_MESSAGES");
saveExclusions(exclusions);
await context.send(`c89; Блокировка сообщений с пометкой "Пересланное" включена.`);
}
return;
}
const isBlockCmd = textCommand.startsWith('/block');
const isUnblockCmd = textCommand.startsWith('/unblock');
if (isBlockCmd || isUnblockCmd) {
let targetId = textCommand.split(' ')[1];
if (!targetId && context.replyMessage && context.replyMessage.text) {
const match = context.replyMessage.text.match(/WA \[(.+?)\]/);
if (match && match[1]) targetId = match[1];
}
if (targetId) {
targetId = targetId.replace(/["']/g, '');
let exclusions = getExclusions();
if (isBlockCmd) {
if (!exclusions.includes(targetId)) {
exclusions.push(targetId);
saveExclusions(exclusions);
await context.send(`c89; Контакт [${targetId}] успешно добавлен в черный список.`);
} else {
await context.send(`b88;A039; Контакт [${targetId}] уже находится в черном списке.`);
}
} else if (isUnblockCmd) {
if (exclusions.includes(targetId)) {
exclusions = exclusions.filter(id => id !== targetId);
saveExclusions(exclusions);
await context.send(`c89; Контакт [${targetId}] удален из черного списка.`);
} else {
await context.send(`b88;A039; Контакта [${targetId}] нет в черном списке.`);
}
}
} else {
await context.send(`d60; Ошибка! Укажите ID: "/block 12345" или ответьте командой /block на сообщение.`);
}
return;
}
}
// --- КОНЕЦ БЛОКА КОМАНД ---
let targetWaId = config.MASTER_WA_PHONE + "@s.whatsapp.net";
if (context.replyMessage && context.replyMessage.text) {
const match = context.replyMessage.text.match(/WA \[(.+?)\]/);
if (match && match[1]) {
targetWaId = match[1];
if (!targetWaId.includes('@')) targetWaId += '@s.whatsapp.net';
}
}
let waPayload = {};
const prefix = '';
const captionText = '';
const messageText = context.text ? prefix + context.text : prefix;
if (context.hasAttachments()) {
const attachment = context.attachments[0];
if (attachment.type === 'photo') {
waPayload = { image: { url: attachment.largeSizeUrl }, caption: messageText };
} else if (attachment.type === 'doc') {
waPayload = { document: { url: attachment.url }, fileName: attachment.title || 'file', mimetype: 'application/octet-stream', caption: messageText };
} else if (attachment.type === 'audio_message') {
waPayload = { audio: { url: attachment.url }, mimetype: 'audio/ogg; codecs=opus', ptt: true };
} else if (attachment.type === 'sticker') {
const stickerImages = attachment.images || attachment.imagesWithBackground;
const bestStickerUrl = stickerImages[stickerImages.length - 1].url;
waPayload = { image: { url: bestStickerUrl }, caption: captionText };
} else {
waPayload = { text: messageText + `\n[Неподдерживаемый медиафайл: ${attachment.type}]` };
}
} else {
if (!context.text) return;
waPayload = { text: messageText };
}
try {
if (globalSock) {
await globalSock.sendMessage(targetWaId, waPayload);
console.log(`[VK -> WA] Ответ успешно отправлен: ${targetWaId}`);
} else {
console.error('[VK -> WA] Сокет WA еще не готов!');
}
} catch (e) {
console.error('[VK -> WA] Ошибка отправки:', e.message);
}
});
vk.updates.start().catch(console.error);
console.log("ВКонтакте: Бот успешно запущен!");
}
// === ИНИЦИАЛИЗАЦИЯ WHATSAPP ===
async function startRelay() {
if (!config) {
config = await initConfig();
getExclusions();
await initVK();
}
const { version } = await fetchLatestBaileysVersion();
console.log(`[WA] Маскируемся под версию: ${version.join('.')}`);
const { state, saveCreds } = await useMultiFileAuthState('auth_info_baileys');
const sock = makeWASocket({
version,
auth: state,
logger: pino({ level: 'silent' }),
browser: Browsers.macOS('Desktop'),
connectTimeoutMs: 60000,
keepAliveIntervalMs: 25000,
markOnlineOnConnect: true
});
globalSock = sock;
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) qrcode.generate(qr, { small: true });
if (connection === 'open') console.log('WhatsApp Мост: СОЕДИНЕНИЕ УСТАНОВЛЕНО!');
if (connection === 'close') {
const errorObject = lastDisconnect?.error;
const statusCode = (errorObject instanceof Boom)?.output?.statusCode || errorObject?.code;
console.log(`Разрыв! Код: ${statusCode}. Ошибка: ${errorObject?.message}`);
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
if (shouldReconnect) {
setTimeout(() => startRelay(), 5000);
}
}
});
sock.ev.on('messages.upsert', async ({ messages }) => {
const m = messages[0];
if (!m.key.fromMe && m.message) {
const remoteJid = m.key.remoteJid;
const senderId = remoteJid.replace('@s.whatsapp.net', '');
const messageType = Object.keys(m.message)[0];
const isForwarded = m.message[messageType]?.contextInfo?.isForwarded === true;
const currentExclusions = getExclusions();
if (currentExclusions.includes("ALL_MESSAGES")) return;
if (isForwarded && currentExclusions.includes("FORWARDED_MESSAGES")) return;
if (currentExclusions.includes(remoteJid) || currentExclusions.includes(senderId)) return;
const senderName = m.pushName || "Имя не указано";
let text = m.message.conversation || m.message.extendedTextMessage?.text || "";
let vkAttachment = null;
const isMedia = ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'].includes(messageType);
if (isMedia) {
try {
const buffer = await downloadMediaMessage(m, 'buffer', {}, { logger: pino({ level: 'silent' }) });
if (messageType === 'imageMessage') {
vkAttachment = await vk.upload.messagePhoto({ source: { value: buffer }, peer_id: config.MY_VK_ID });
} else if (messageType === 'audioMessage') {
vkAttachment = await vk.upload.audioMessage({ source: { value: buffer }, peer_id: config.MY_VK_ID });
} else if (messageType === 'stickerMessage') {
vkAttachment = await vk.upload.messageDocument({ source: { value: buffer, filename: 'sticker.webp' }, peer_id: config.MY_VK_ID });
text = `[Прислан стикер 👾]\n${text}`;
} else {
const fileName = m.message[messageType].fileName || 'document.file';
vkAttachment = await vk.upload.messageDocument({ source: { value: buffer, filename: fileName }, peer_id: config.MY_VK_ID });
}
const caption = m.message[messageType]?.caption || "";
if (caption) text += `\n${caption}`;
} catch (e) {
console.error("[WA -> VK] Ошибка медиа:", e.message);
text = `[Ошибка загрузки файла]\n${text}`;
}
}
if (text || vkAttachment) {
await vk.api.messages.send({
peer_id: config.MY_VK_ID,
message: `📩 WA [${senderId}] (${senderName}):\n${text}`,
attachment: vkAttachment,
random_id: Math.floor(Math.random() * 1000000)
});
}
}
});
}
// Запуск приложения
startRelay();
Руководство по развертыванию на Linux (Ubuntu 22.04 / 24.04 VDS)
Размещение моста на зарубежном VDS (например, Казахстан, Нидерланды) снимает необходимость в использовании локальных прокси-агентов (Proxifier/Netch) — сервер общается с серверами WhatsApp напрямую.
Шаг 1. Установка Node.js через Node Version Manager (NVM): Для стабильной работы и поддержки современных операторов требуется версия Node.js не ниже 18.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20
Шаг 2. Загрузка проекта и чистая установка зависимостей: Создайте рабочую директорию и инициализируйте модули:
mkdir /root/wa-bridge && cd /root/wa-bridge
# (Перенесите файл relay.js в эту папку)
npm install @whiskeysockets/baileys vk-io @hapi/boom qrcode-terminal pino
Шаг 3. Первоначальный интерактивный запуск: Запустите скрипт вручную в терминале для генерации конфигурации и сканирования QR-кода:
node relay.js
Скрипт поочередно запросит в консоли: VK_TOKEN (токен группы с правами на сообщения), VK_GROUP_ID, MY_VK_ID (ваш ID аккаунта для получения уведомлений) и MASTER_WA_PHONE. Будет сгенерирован файл config.json. Отсканируйте появившийся в консоли QR-код вашим смартфоном. После успешной авторизации остановите процесс через Ctrl + C.
Шаг 4. Демонизация и обеспечение "неубиваемости" через PM2:
npm install -g pm2
pm2 start relay.js --name "wa-bridge"
pm2 save
pm2 startup
(Скопируйте и выполните команду, которую сгенерирует pm2 startup для прописи в системный автозапуск systemd).
Для мониторинга состояния моста используйте команды pm2 status и просмотр логов в реальном времени pm2 logs wa-bridge.
Руководство по развертыванию на Windows (Локальный ПК или Сервер)
Шаг 1. Подготовка окружения (Портативный Node.js)
Чтобы не засорять систему установщиками, мы соберем «портативную» сборку, которую можно переносить на флешке или копировать на любой сервер.
-
Перейдите на официальный сайт nodejs.org в раздел Download -> Prebuilt Binaries.
-
Выберите Windows (x64) и обязательно скачайте формат ZIP (не .msi).
-
Распакуйте архив. Из всей папки нам нужен только один файл —
node.exe. -
Создайте рабочую папку проекта (например,
C:\wa-bridge) и положитеnode.exeтуда.
Шаг 2. Перенос файлов и установка зависимостей
-
Положите ваш скрипт
relay.jsв папкуC:\wa-bridge. -
Откройте командную строку (cmd) в этой папке и установите модули одной командой:
npm install @whiskeysockets/baileys vk-io @hapi/boom qrcode-terminal pino
Шаг 3. Первоначальный интерактивный запуск
Запустите скрипт через командную строку:
node.exe relay.js
-
Введите в консоли запрашиваемые токены и ID для формирования файла
config.json. -
Отсканируйте появившийся QR-код вашим телефоном.
-
Убедившись, что в консоли загорелось
WhatsApp Мост: СОЕДИНЕНИЕ УСТАНОВЛЕНО УСПЕШНО!, закройте окно командной строки.
Шаг 4. Настройка скрытого автозапуска (без висящих окон)
Чтобы черное окно консоли не висело на рабочем столе сервера и его случайно не закрыли менеджеры, мы настроим запуск в скрытом режиме Windows.
-
В папке проекта (
C:\wa-bridge) создайте текстовый файл и переименуйте его вstart.vbs(расширение должно быть строго.vbs). -
Откройте его через Блокнот, вставьте следующий код и сохраните:
Set WshShell = CreateObject("WScript.Shell")
' Запускаем локальный node.exe со скриптом в невидимом режиме (аргумент 0)
WshShell.Run "node.exe relay.js", 0, False
Теперь для старта моста достаточно дважды кликнуть по файлу start.vbs. Процесс тихо запустится в оперативной памяти.
-
Как остановить мост на Windows? Откройте Диспетчер задач (Ctrl+Shift+Esc) -> вкладка «Подробности» (Details) -> найдите процесс
node.exeи нажмите «Снять задачу». -
Как настроить автозапуск при старте ПК? Просто нажмите комбинацию
Win + R, введитеshell:startupи перетащите ярлык файлаstart.vbsв открывшуюся системную папку «Автозагрузка».
Перспективы интеграции с 1С:Предприятие
Данный скрипт является идеальной "прослойкой". Так как он написан на Node.js, его можно легко расширить:
-
Добавить отправку HTTP-запросов (
fetch/axios) в сторону HTTP-сервиса вашей 1С, передавая тудаsenderId,textи ссылки на вложения при каждом событииmessages.upsert. -
Поднять внутри скрипта легковесный
Express.jsвеб-сервер, чтобы сама 1С могла слать POST-запросы наhttp://localhost:port/send, а скрипт мгновенно пересылал сообщения клиентам в WhatsApp (черезglobalSock.sendMessage).
Это превращает бесплатный мост в полноценный корпоративный шлюз бизнес-уведомлений.
Вступайте в нашу телеграмм-группу Инфостарт