Implantação da geração de mensagem de forma livre e mudança na engine de mensagem
This commit is contained in:
22
whatsapp/Dockerfile
Normal file
22
whatsapp/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
# Ponte WhatsApp (Baileys). Container separado porque nao existe biblioteca Ruby
|
||||
# que fale o protocolo do WhatsApp Web — e porque isolar a sessao num processo
|
||||
# proprio evita que uma queda dela derrube o Puma.
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Baileys usa libs nativas de cripto em algumas plataformas.
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY server.js ./
|
||||
|
||||
# A sessao pareada vive AQUI e precisa de volume: sem isso, cada deploy exige
|
||||
# escanear o QR de novo.
|
||||
VOLUME ["/data"]
|
||||
ENV WHATSAPP_DATA_DIR=/data
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "server.js"]
|
||||
15
whatsapp/package.json
Normal file
15
whatsapp/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "reem-whatsapp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Ponte HTTP entre o Rails e o WhatsApp via Baileys (sessao pareada por QR code).",
|
||||
"main": "server.js",
|
||||
"scripts": { "start": "node server.js" },
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "^6.7.9",
|
||||
"pino": "^9.5.0",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"engines": { "node": ">=20" }
|
||||
}
|
||||
205
whatsapp/server.js
Normal file
205
whatsapp/server.js
Normal file
@@ -0,0 +1,205 @@
|
||||
// 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'));
|
||||
Reference in New Issue
Block a user