248 lines
9.3 KiB
JavaScript
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'));
|