Bot: endpoint /debug para diagnosticar entrantes + estado de conexión

GET /debug (Basic auth, QR_TOKEN) devuelve el estado de la conexión de WhatsApp
y un anillo de los últimos mensajes entrantes (remoteJid real, fromMe, tipo) y
el resultado del matching lead↔teléfono. Para diagnosticar por qué el bot no
responde (conexión zombi vs formato de jid @lid vs no llega el mensaje).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-09 17:51:10 +02:00
parent a8b6d62dd6
commit 8d565e5fb0
2 changed files with 51 additions and 4 deletions

View File

@@ -12,6 +12,9 @@ export class WebhookListener implements OnApplicationBootstrap {
// Estado de vinculación de WhatsApp, alimentado por WhatsappService (sin dependencia circular). // Estado de vinculación de WhatsApp, alimentado por WhatsappService (sin dependencia circular).
private qrActual: string | null = null; private qrActual: string | null = null;
private conectado = false; private conectado = false;
// Diagnóstico: estado de conexión + últimos eventos entrantes (anillo de 15).
private connState: Record<string, unknown> = {};
private inbound: any[] = [];
constructor(private readonly api: ApiClient) {} constructor(private readonly api: ApiClient) {}
@@ -33,12 +36,20 @@ export class WebhookListener implements OnApplicationBootstrap {
this.conectado = b; this.conectado = b;
if (b) this.qrActual = null; if (b) this.qrActual = null;
} }
setConnState(o: Record<string, unknown>) {
this.connState = o;
}
pushInbound(o: Record<string, unknown>) {
this.inbound.unshift(o);
if (this.inbound.length > 15) this.inbound.pop();
}
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
const url = req.url || ''; const url = req.url || '';
if (req.method === 'GET') { if (req.method === 'GET') {
if (url.startsWith('/qr')) return this.handleQrPage(req, res); if (url.startsWith('/qr')) return this.handleQrPage(req, res);
if (url.startsWith('/debug')) return this.handleDebug(req, res);
res.writeHead(404).end('Not Found'); res.writeHead(404).end('Not Found');
return; return;
} }
@@ -84,15 +95,37 @@ export class WebhookListener implements OnApplicationBootstrap {
return crypto.timingSafeEqual(ha, hb); return crypto.timingSafeEqual(ha, hb);
} }
// Página de vinculación: muestra el QR de Baileys como imagen escaneable. // Auth Basic: contraseña = QR_TOKEN (en cabecera, nunca en la URL).
// Auth por cabecera (HTTP Basic, contraseña = QR_TOKEN), nunca por query string. private basicAuthOk(req: http.IncomingMessage): boolean {
private async handleQrPage(req: http.IncomingMessage, res: http.ServerResponse) {
const expected = process.env.QR_TOKEN || ''; const expected = process.env.QR_TOKEN || '';
const auth = (req.headers['authorization'] as string) || ''; const auth = (req.headers['authorization'] as string) || '';
const pass = auth.startsWith('Basic ') const pass = auth.startsWith('Basic ')
? Buffer.from(auth.slice(6), 'base64').toString('utf8').split(':').slice(1).join(':') ? Buffer.from(auth.slice(6), 'base64').toString('utf8').split(':').slice(1).join(':')
: ''; : '';
if (!this.tokenValido(pass, expected)) { return this.tokenValido(pass, expected);
}
// Diagnóstico: estado de conexión + últimos eventos entrantes. Misma auth que /qr.
private handleDebug(req: http.IncomingMessage, res: http.ServerResponse) {
if (!this.basicAuthOk(req)) {
res
.writeHead(401, {
'WWW-Authenticate': 'Basic realm="Reformix QR", charset="UTF-8"',
'Content-Type': 'application/json',
'Referrer-Policy': 'no-referrer',
})
.end(JSON.stringify({ ok: false, error: 'No autorizado' }));
return;
}
res
.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' })
.end(JSON.stringify({ conectado: this.conectado, connState: this.connState, inbound: this.inbound }, null, 2));
}
// Página de vinculación: muestra el QR de Baileys como imagen escaneable.
// Auth por cabecera (HTTP Basic, contraseña = QR_TOKEN), nunca por query string.
private async handleQrPage(req: http.IncomingMessage, res: http.ServerResponse) {
if (!this.basicAuthOk(req)) {
res res
.writeHead(401, { .writeHead(401, {
'WWW-Authenticate': 'Basic realm="Reformix QR", charset="UTF-8"', 'WWW-Authenticate': 'Basic realm="Reformix QR", charset="UTF-8"',

View File

@@ -196,6 +196,13 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
this.sock.ev.on('connection.update', (update) => { this.sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update; const { connection, lastDisconnect, qr } = update;
this.webhookListener.setConnState({
connection: connection ?? null,
hasQr: !!qr,
lastDisconnect: (lastDisconnect?.error as Boom)?.output?.statusCode ?? null,
at: new Date().toISOString(),
});
if (qr) { if (qr) {
QRCode.generate(qr, { small: true }); QRCode.generate(qr, { small: true });
console.log('\n📲 Escanea este QR con WhatsApp (o abre la página /qr, protegida con QR_TOKEN)\n'); console.log('\n📲 Escanea este QR con WhatsApp (o abre la página /qr, protegida con QR_TOKEN)\n');
@@ -218,6 +225,12 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
this.sock.ev.on('messages.upsert', async ({ messages, type }) => { this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
if (type !== 'notify') return; if (type !== 'notify') return;
for (const msg of messages) { for (const msg of messages) {
this.webhookListener.pushInbound({
remoteJid: msg.key.remoteJid ?? null,
fromMe: !!msg.key.fromMe,
msgType: msg.message ? Object.keys(msg.message)[0] : null,
at: new Date().toISOString(),
});
if (msg.key.fromMe) continue; if (msg.key.fromMe) continue;
if (!msg.key.remoteJid) continue; if (!msg.key.remoteJid) continue;
if (msg.key.remoteJid.includes('@g.us')) continue; if (msg.key.remoteJid.includes('@g.us')) continue;
@@ -274,6 +287,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
} }
} }
this.webhookListener.pushInbound({ stage: 'match', telefono, leadId: leadId ?? null, at: new Date().toISOString() });
if (!leadId) { if (!leadId) {
this.logger.log(`Mensaje ignorado de ${telefono}: lead no registrado. Debe iniciarse desde la web.`); this.logger.log(`Mensaje ignorado de ${telefono}: lead no registrado. Debe iniciarse desde la web.`);
return null; return null;