// Ponte HTTP entre o Rails e o WhatsApp, via Baileys (sessao pareada por QR). // // ⚠️ NAO E API OFICIAL. Baileys fala o protocolo do WhatsApp Web por engenharia // reversa. Funciona e e gratuito, mas esta fora dos Termos do WhatsApp: a // Meta pode banir o numero. Use um chip dedicado, nao o numero principal da // operacao, e respeite o intervalo entre mensagens (o Rails controla isso; // aqui a fila serializa os envios para nunca sair rajada em paralelo). // // Superficie HTTP (toda rota, menos /health, exige Authorization: Bearer TOKEN): // GET /health -> { ok: true } (sem auth, para o compose) // GET /status -> { conectado, numero, qr, desde } // POST /enviar -> { para, texto } => { ok, id } // POST /logout -> encerra a sessao e apaga as credenciais // // A porta NAO e publicada no docker-compose: so o container do Rails alcanca. import { createServer } from 'node:http'; import { readFile, rm, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import makeWASocket, { DisconnectReason, useMultiFileAuthState, fetchLatestBaileysVersion, } from '@whiskeysockets/baileys'; import pino from 'pino'; import QRCode from 'qrcode'; const PORTA = Number(process.env.PORT || 3001); const TOKEN = process.env.WHATSAPP_TOKEN || ''; const DATA_DIR = process.env.WHATSAPP_DATA_DIR || '/data'; const AUTH_DIR = join(DATA_DIR, 'auth'); const log = pino({ level: process.env.LOG_LEVEL || 'info' }); // Estado observavel pela tela do admin. const estado = { conectado: false, numero: null, qr: null, // data URL do QR enquanto nao pareado desde: null, ultimoErro: null, }; let sock = null; let parando = false; // ── Fila serial de envios ─────────────────────────────────────────────────── // Disparo em paralelo para dezenas de contatos e a forma mais rapida de perder // o numero. Uma promise encadeada garante um envio por vez, na ordem. let fila = Promise.resolve(); function enfileirar(tarefa) { const resultado = fila.then(tarefa, tarefa); fila = resultado.catch(() => {}); return resultado; } async function conectar() { await mkdir(AUTH_DIR, { recursive: true }); const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); const { version } = await fetchLatestBaileysVersion(); sock = makeWASocket({ version, auth: state, printQRInTerminal: false, logger: pino({ level: 'silent' }), browser: ['Reem Notas', 'Chrome', '1.0.0'], markOnlineOnConnect: false, }); sock.ev.on('creds.update', saveCreds); sock.ev.on('connection.update', async (update) => { const { connection, lastDisconnect, qr } = update; if (qr) { estado.qr = await QRCode.toDataURL(qr, { margin: 1, width: 320 }); estado.conectado = false; log.info('QR novo gerado — aguardando leitura'); } if (connection === 'open') { estado.conectado = true; estado.qr = null; estado.desde = new Date().toISOString(); estado.ultimoErro = null; estado.numero = (sock.user?.id || '').split(':')[0] || null; log.info({ numero: estado.numero }, 'WhatsApp conectado'); } if (connection === 'close') { estado.conectado = false; const codigo = lastDisconnect?.error?.output?.statusCode; estado.ultimoErro = lastDisconnect?.error?.message || null; // loggedOut = a sessao foi encerrada no celular. Reconectar em loop so // gera trafego suspeito; as credenciais precisam ser apagadas e o QR // lido de novo. if (codigo === DisconnectReason.loggedOut) { log.warn('sessao encerrada no aparelho — apagando credenciais'); await rm(AUTH_DIR, { recursive: true, force: true }).catch(() => {}); estado.numero = null; } if (!parando) { log.info({ codigo }, 'reconectando em 5s'); setTimeout(() => conectar().catch((e) => log.error(e)), 5000); } } }); } // ── HTTP ──────────────────────────────────────────────────────────────────── function json(res, status, corpo) { const texto = JSON.stringify(corpo); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(texto); } function autorizado(req) { if (!TOKEN) return false; // sem token configurado, nada passa const header = req.headers.authorization || ''; const enviado = header.startsWith('Bearer ') ? header.slice(7) : ''; // Comparacao de tamanho fixo evita vazar o token por tempo de resposta. if (enviado.length !== TOKEN.length) return false; let diff = 0; for (let i = 0; i < TOKEN.length; i += 1) diff |= enviado.charCodeAt(i) ^ TOKEN.charCodeAt(i); return diff === 0; } async function lerCorpo(req) { const partes = []; let total = 0; for await (const parte of req) { total += parte.length; if (total > 64 * 1024) throw new Error('corpo grande demais'); partes.push(parte); } return partes.length ? JSON.parse(Buffer.concat(partes).toString('utf8')) : {}; } // "+55 11 92005-1157" / "5511920051157" -> "5511920051157@s.whatsapp.net" function jidDe(numero) { const digitos = String(numero || '').replace(/\D/g, ''); if (digitos.length < 12 || digitos.length > 13) return null; return `${digitos}@s.whatsapp.net`; } const servidor = createServer(async (req, res) => { try { const url = new URL(req.url, 'http://local'); if (url.pathname === '/health') return json(res, 200, { ok: true }); if (!autorizado(req)) return json(res, 401, { erro: 'nao autorizado' }); if (url.pathname === '/status' && req.method === 'GET') { return json(res, 200, { conectado: estado.conectado, numero: estado.numero, qr: estado.qr, desde: estado.desde, ultimo_erro: estado.ultimoErro, }); } if (url.pathname === '/enviar' && req.method === 'POST') { const { para, texto } = await lerCorpo(req); const jid = jidDe(para); if (!jid) return json(res, 422, { erro: 'numero invalido' }); if (!texto || !String(texto).trim()) return json(res, 422, { erro: 'texto vazio' }); if (!estado.conectado || !sock) return json(res, 503, { erro: 'whatsapp desconectado' }); const resultado = await enfileirar(async () => { // Mandar para numero que nao tem WhatsApp e um dos sinais que a Meta usa // para marcar conta de spam — conferir antes vale o round-trip. const [existe] = await sock.onWhatsApp(jid.split('@')[0]); if (!existe?.exists) throw new Error('numero sem WhatsApp'); return sock.sendMessage(jid, { text: String(texto) }); }); return json(res, 200, { ok: true, id: resultado?.key?.id || null }); } if (url.pathname === '/logout' && req.method === 'POST') { if (sock) await sock.logout().catch(() => {}); await rm(AUTH_DIR, { recursive: true, force: true }).catch(() => {}); estado.conectado = false; estado.numero = null; estado.qr = null; setTimeout(() => conectar().catch((e) => log.error(e)), 1000); return json(res, 200, { ok: true }); } return json(res, 404, { erro: 'rota desconhecida' }); } catch (e) { log.error({ err: e }, 'falha na requisicao'); return json(res, 500, { erro: e.message || 'erro interno' }); } }); process.on('SIGTERM', () => { parando = true; servidor.close(() => process.exit(0)); }); servidor.listen(PORTA, '0.0.0.0', () => log.info(`ponte WhatsApp na porta ${PORTA}`)); conectar().catch((e) => log.error({ err: e }, 'falha ao conectar'));