Files
Reem-Notas/whatsapp/server.js
2026-08-24 19:00:21 -03:00

248 lines
9.3 KiB
JavaScript

// 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')) : {};
}
// Destino -> JID. Duas formas:
// "+55 11 92005-1157" / "5511920051157" -> "5511920051157@s.whatsapp.net"
// "120363012345678901@g.us" -> passa direto (grupo)
// O grupo NAO e telefone: e um identificador proprio do WhatsApp, entao vem
// pronto do cadastro e so precisa ser validado no formato.
function jidDe(destino) {
const valor = String(destino || '').trim();
if (/^\d{5,}@g\.us$/.test(valor)) return valor;
if (/^\d{10,15}@s\.whatsapp\.net$/.test(valor)) return valor;
const digitos = valor.replace(/\D/g, '');
if (digitos.length < 12 || digitos.length > 13) return null;
return `${digitos}@s.whatsapp.net`;
}
function ehGrupo(jid) {
return String(jid).endsWith('@g.us');
}
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.
//
// A checagem NAO vale para grupo: `onWhatsApp` responde sobre numeros e
// devolveria vazio para um JID de grupo, derrubando um envio valido.
if (!ehGrupo(jid)) {
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 });
}
// Grupos de que o numero conectado PARTICIPA. Existe porque o id de um grupo
// e opaco (`120363...@g.us`) — digitar na mao e pedir erro. O admin escolhe
// pelo nome na tela e o id vai junto.
if (url.pathname === '/grupos' && req.method === 'GET') {
if (!estado.conectado || !sock) return json(res, 503, { erro: 'whatsapp desconectado' });
const meu = (sock.user?.id || '').split(':')[0];
const todos = await sock.groupFetchAllParticipating();
const grupos = Object.values(todos || {}).map((g) => {
const eu = (g.participants || []).find((p) => String(p.id).startsWith(meu));
return {
id: g.id,
nome: g.subject || g.id,
participantes: (g.participants || []).length,
// `announce` = so admin escreve. Sem ser admin, o envio seria recusado
// e o erro so apareceria no log — melhor avisar antes, na tela.
somente_admin: Boolean(g.announce),
sou_admin: Boolean(eu && eu.admin),
};
}).sort((a, b) => a.nome.localeCompare(b.nome));
return json(res, 200, { grupos });
}
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'));