Configuracion de agente de whastapp paratrabajar con la estructura propuesta

This commit is contained in:
unknown
2026-06-07 17:51:53 -04:00
parent d3189d7277
commit fec365bb57
28 changed files with 5316 additions and 1748 deletions

View File

@@ -1,9 +1,10 @@
import { EventEmitter } from 'events';
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from "@nestjs/common";
} from '@nestjs/common';
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
@@ -11,33 +12,42 @@ import makeWASocket, {
WASocket,
downloadMediaMessage,
normalizeMessageContent,
} from "@whiskeysockets/baileys";
import { Boom } from "@hapi/boom";
import * as path from "path";
const pino = require("pino");
const QRCode = require("qrcode-terminal");
import { LeadsService } from "../leads/leads.service";
import { ConversacionService } from "../conversacion/conversacion.service";
import { ClaudeService } from "../claude/claude.service";
import { MediaService } from "../media/media.service";
import { WhatsappDebounceService } from "./whatsapp-debounce.service";
import { wrapSocket } from "baileys-antiban";
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import * as path from 'path';
const pino = require('pino');
const QRCode = require('qrcode-terminal');
import { LeadsService } from '../leads/leads.service';
import { ConversacionService } from '../conversacion/conversacion.service';
import { ClaudeService } from '../claude/claude.service';
import { MediaService } from '../media/media.service';
import { WhatsappDebounceService } from './whatsapp-debounce.service';
import { WebhookListener } from '../webhook/webhook-listener';
import { ApiClient } from '../api/api-client.service';
import { wrapSocket } from 'baileys-antiban';
const ESTADOS_TERMINALES = [
"completado",
"no_viable",
"perdido",
"fin_viable",
"fin_no_viable",
];
export const pdfEmitter = new EventEmitter();
interface LeadContext {
leadId: string;
telefono: string;
nombre: string;
botStep: string;
viable: boolean | null;
}
@Injectable()
export class WhatsappService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(WhatsappService.name);
private sock: WASocket | null = null;
private authDir = path.join(process.cwd(), "auth_info_baileys");
private authDir = path.join(process.cwd(), 'auth_info_baileys');
private readonly ultimoMsgPorJid = new Map<string, any>();
private baileysLogger = pino({ level: "info" });
private baileysLogger = pino({ level: 'info' });
// leadId por JID
private readonly jidToLeadId = new Map<string, string>();
// contexto de lead por leadId
private readonly leadCache = new Map<string, LeadContext>();
constructor(
private readonly leadsService: LeadsService,
@@ -45,20 +55,51 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
private readonly claudeService: ClaudeService,
private readonly mediaService: MediaService,
private readonly debounceService: WhatsappDebounceService,
private readonly webhookListener: WebhookListener,
private readonly api: ApiClient,
) {}
async onModuleInit() {
await this.conectar();
this.escucharPdf();
}
async onModuleDestroy() {
if (this.sock) {
this.sock.end(undefined);
}
if (this.sock) this.sock.end(undefined);
}
private escucharPdf() {
pdfEmitter.on('pdf', async (payload: { leadId: string; telefono: string; pdfBase64: string; filename: string }) => {
this.logger.log(`[PDF] Recibido para leadId=${payload.leadId}`);
// Buscar JID por teléfono
let jid: string | null = null;
for (const [j, lid] of this.jidToLeadId) {
if (lid === payload.leadId) {
jid = j;
break;
}
}
if (!jid) {
jid = `${payload.telefono}@s.whatsapp.net`;
}
if (!this.sock) return;
try {
const safeSock = wrapSocket(this.sock);
await safeSock.sendMessage(jid, {
document: Buffer.from(payload.pdfBase64, 'base64'),
mimetype: 'application/pdf',
fileName: payload.filename,
caption: 'Aquí tienes tu presupuesto. Si tienes cualquier duda, estamos aquí.',
});
this.logger.log(`PDF enviado a ${jid}`);
} catch (err: any) {
this.logger.error(`Error enviando PDF a ${jid}: ${err.message}`);
}
});
}
private normalizarTelefono(jid: string): string {
return jid.split("@")[0].replace(/\D/g, "");
return jid.split('@')[0].replace(/\D/g, '');
}
private calcularDelayEscritura(longitudTexto: number): number {
@@ -76,7 +117,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
const { version } = await fetchLatestBaileysVersion();
this.baileysLogger = pino({ level: "info" }) as any;
this.baileysLogger = pino({ level: 'info' }) as any;
this.sock = makeWASocket({
version,
@@ -88,56 +129,37 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
syncFullHistory: false,
});
this.sock.ev.on("creds.update", saveCreds);
this.sock.ev.on('creds.update', saveCreds);
this.sock.ev.on("connection.update", (update) => {
this.sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
QRCode.generate(qr, { small: true });
console.log("\n📲 Escanea este QR con WhatsApp\n");
console.log('\n📲 Escanea este QR con WhatsApp\n');
}
if (connection === "close") {
if (connection === 'close') {
const shouldReconnect =
(lastDisconnect?.error as Boom)?.output?.statusCode !==
DisconnectReason.loggedOut;
this.logger.warn(
`Conexion cerrada. Reconectar: ${shouldReconnect}. Razon: ${lastDisconnect?.error?.message}`,
);
if (shouldReconnect) {
setTimeout(() => this.conectar(), 5000);
} else {
this.logger.error(
"Sesion cerrada (logged out). Elimina auth_info_baileys y reinicia.",
);
}
} else if (connection === "open") {
this.logger.log(
"✅ WhatsApp conectado. Luisa esta lista para recibir mensajes.",
);
(lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut;
this.logger.warn(`Conexion cerrada. Reconectar: ${shouldReconnect}.`);
if (shouldReconnect) setTimeout(() => this.conectar(), 5000);
else this.logger.error('Sesion cerrada (logged out).');
} else if (connection === 'open') {
this.logger.log('✅ WhatsApp conectado. Luisa esta lista.');
}
});
this.sock.ev.on("messages.upsert", async ({ messages, type }) => {
if (type !== "notify") return;
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
if (type !== 'notify') return;
for (const msg of messages) {
if (msg.key.fromMe) continue;
if (!msg.key.remoteJid) continue;
if (msg.key.remoteJid.includes("@g.us")) continue;
if (msg.key.remoteJid.includes('@g.us')) continue;
const telefonoNormalizado = this.normalizarTelefono(msg.key.remoteJid);
const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, "");
if (allowedNumber && telefonoNormalizado !== allowedNumber) {
this.logger.debug(
`Mensaje ignorado: ${telefonoNormalizado} no coincide con ALLOWED_NUMBER`,
);
continue;
}
const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, '');
if (allowedNumber && telefonoNormalizado !== allowedNumber) continue;
await this.encolarMensaje(msg);
}
@@ -147,21 +169,15 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
private extraerTextoPlano(msg: any): string | null {
const msgContent = msg.message;
if (!msgContent) return null;
if (msgContent.conversation || msgContent.extendedTextMessage) {
const texto =
msgContent.conversation || msgContent.extendedTextMessage?.text || "";
const texto = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
return texto.trim() ? texto : null;
}
return null;
}
private crearMsgConTexto(msg: any, texto: string): any {
return {
...msg,
message: { conversation: texto },
};
return { ...msg, message: { conversation: texto } };
}
private async encolarMensaje(msg: any): Promise<void> {
@@ -174,7 +190,6 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
}
this.ultimoMsgPorJid.set(jid, msg);
await this.debounceService.add(jid, textoPlano, async (combinedMessage) => {
const baseMsg = this.ultimoMsgPorJid.get(jid) ?? msg;
this.ultimoMsgPorJid.delete(jid);
@@ -182,179 +197,184 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
});
}
private async getOrCreateContext(telefono: string, jid: string): Promise<LeadContext | null> {
const leadId = this.webhookListener.getLeadIdByTelefono(telefono);
if (!leadId) {
this.logger.log(`Mensaje ignorado de ${telefono}: lead no registrado. Debe iniciarse desde la web.`);
return null;
}
this.webhookListener.registerJid(telefono, jid);
this.jidToLeadId.set(jid, leadId);
let ctx = this.leadCache.get(leadId);
if (!ctx) {
const lead = await this.api.getLead(leadId);
ctx = {
leadId,
telefono,
nombre: lead?.nombre || '',
botStep: lead?.botStep || 'nuevo',
viable: lead?.viable ?? null,
};
this.leadCache.set(leadId, ctx);
}
return ctx;
}
private async procesarMensaje(msg: any): Promise<void> {
const jid = msg.key.remoteJid!;
if (jid.includes('@g.us')) return;
if (jid.includes("@g.us")) return;
const telefono = jid.split("@")[0];
const telefono = jid.split('@')[0];
try {
let lead = await this.leadsService.findOrCreate(telefono);
const ctx = await this.getOrCreateContext(telefono, jid);
if (!ctx) return;
if (ESTADOS_TERMINALES.includes(lead.estado_actual)) {
this.logger.log(
`Lead id=${lead.id} en estado=${lead.estado_actual}. Ignorando.`,
);
return;
}
const primerMensajeDeUsuario = !this.jidToLeadId.has(jid);
let textoNormalizado = "";
let textoNormalizado = '';
const msgContent = normalizeMessageContent(msg.message);
if (!msgContent) return;
if (msgContent.conversation || msgContent.extendedTextMessage) {
textoNormalizado =
msgContent.conversation || msgContent.extendedTextMessage?.text || "";
textoNormalizado = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
} else if (msgContent.audioMessage) {
const audioMeta = msgContent.audioMessage;
const mimeType = audioMeta.mimetype || "audio/ogg; codecs=opus";
this.logger.log(
`[AUDIO 1/4] Recibido — lead=${lead.id}, ptt=${audioMeta.ptt ?? false}, seconds=${audioMeta.seconds ?? "?"}, mimetype=${mimeType}, fileLength=${audioMeta.fileLength ?? "?"}, url=${audioMeta.url ? "si" : "no"}`,
);
if (!this.sock) {
this.logger.error("[AUDIO 1/4] Socket no disponible para descargar audio");
return;
}
const buffer = await downloadMediaMessage(
msg as any,
"buffer",
{},
{
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
},
);
const audioBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
const magicHex = audioBuffer.subarray(0, 4).toString("hex");
this.logger.log(
`[AUDIO 1/4] Buffer descargado — size=${audioBuffer.length} bytes, magic_hex=${magicHex}, esperado_ogg=4f676753`,
);
textoNormalizado = await this.mediaService.transcribirAudio(
audioBuffer,
mimeType,
);
this.logger.log(
`[AUDIO 1/4] Transcripcion recibida en procesarMensaje — "${textoNormalizado.slice(0, 200).replace(/\n/g, "\\n")}"`,
);
} else if (msgContent.imageMessage) {
this.logger.log(
`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`,
);
const mimeType = audioMeta.mimetype || 'audio/ogg; codecs=opus';
this.logger.log(`[AUDIO] Recibido — lead=${ctx.leadId}`);
if (!this.sock) return;
const buffer = await downloadMediaMessage(
msg as any,
"buffer",
{},
{
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
},
);
const mimeType = msgContent.imageMessage.mimetype || "image/jpeg";
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
});
const audioBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
textoNormalizado = await this.mediaService.transcribirAudio(audioBuffer, mimeType);
} else if (msgContent.imageMessage) {
this.logger.log(`Imagen recibida de lead ${ctx.leadId}`);
if (!this.sock) return;
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
});
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
textoNormalizado = await this.mediaService.inferirImagen(
Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer),
mimeType,
lead.estado_actual,
'en_proceso',
);
if (msgContent.imageMessage.caption) {
textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`;
}
} else {
this.logger.log(
`Tipo de mensaje no soportado de lead id=${lead.id}. Ignorando.`,
);
this.logger.log(`Tipo de mensaje no soportado de lead ${ctx.leadId}. Ignorando.`);
return;
}
if (!textoNormalizado.trim()) return;
this.logger.log(`USUARIO [${telefono}]: ${textoNormalizado}`);
await this.conversacionService.guardarMensaje(
lead.id,
"user",
if (primerMensajeDeUsuario) {
await this.api.registrarIntento(ctx.leadId, 'whatsapp', 1, 'exitoso', true);
}
if (msgContent.imageMessage) {
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
});
const base64 = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64');
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
await this.api.enviarIngesta(ctx.leadId, [{
tipo: 'foto',
imagen: `data:${mimeType};base64,${base64}`,
zona: 'otro',
momento: 'antes',
}]);
}
await this.conversacionService.guardarMensaje(ctx.leadId, 'user', textoNormalizado, {
botStep: ctx.botStep,
});
const historial = await this.conversacionService.obtenerHistorialComoMessages(ctx.leadId);
const leadParaClaude = {
id: ctx.leadId,
telefono: ctx.telefono,
nombre: ctx.nombre,
estado_actual: ctx.botStep || 'nuevo',
espacio: null as string | null,
rango_m2: null as string | null,
estilo: null as string | null,
urgencia: null as string | null,
presupuesto_declarado: null as string | null,
viable: ctx.viable as boolean | null,
email: null as string | null,
};
const { respuesta, entidad, viable, nuevoEstado } = await this.claudeService.llamarClaude(
leadParaClaude as any,
historial.slice(0, -1),
textoNormalizado,
);
const historial =
await this.conversacionService.obtenerHistorialComoMessages(lead.id);
const { respuesta, entidad, viable, nuevoEstado } =
await this.claudeService.llamarClaude(
lead,
historial.slice(0, -1),
textoNormalizado,
);
this.logger.log(`LUISA [${telefono}]: ${respuesta}`);
if (
(entidad && Object.keys(entidad).length > 0) ||
nuevoEstado ||
(viable !== undefined && viable !== null)
) {
lead = await this.leadsService.persistirTurno(lead.id, entidad ?? {}, {
nuevoEstado,
viable,
});
this.logger.log(
`Lead id=${lead.id} en DB — estado=${lead.estado_actual}, espacio=${lead.espacio ?? "-"}, rango_m2=${lead.rango_m2 ?? "-"}, estilo=${lead.estilo ?? "-"}, urgencia=${lead.urgencia ?? "-"}, presupuesto=${lead.presupuesto_declarado ?? "-"}`,
);
if ((entidad && Object.keys(entidad).length > 0) || nuevoEstado || viable !== undefined) {
const entidadMap: Record<string, unknown> = {};
if (entidad) {
for (const [k, v] of Object.entries(entidad)) {
const mapped = this.mapearCampoALegacy(k);
entidadMap[mapped] = v;
}
}
await this.leadsService.persistirTurno(ctx.leadId, entidadMap, { nuevoEstado, viable });
if (nuevoEstado) ctx.botStep = nuevoEstado;
if (viable !== undefined) ctx.viable = viable;
this.logger.log(`Lead ${ctx.leadId} persistido — estado=${nuevoEstado || ctx.botStep}`);
}
await this.conversacionService.guardarMensaje(
lead.id,
"assistant",
respuesta,
);
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', respuesta, {
botStep: ctx.botStep,
});
await this.enviarMensaje(jid, respuesta);
} catch (error) {
this.logger.error(
`Error procesando mensaje de ${telefono}: ${error.message}`,
error.stack,
);
} catch (error: any) {
this.logger.error(`Error procesando mensaje de ${telefono}: ${error.message}`, error.stack);
}
}
private mapearCampoALegacy(campo: string): string {
const map: Record<string, string> = {
espacio: 'espacio',
rango_m2: 'rangoM2',
estilo: 'estilo',
urgencia: 'urgencia',
presupuesto_declarado: 'presupuestoDeclarado',
nombre: 'nombre',
};
return map[campo] || campo;
}
async enviarMensaje(jid: string, texto: string): Promise<void> {
if (!this.sock) return;
try {
const jidPresencia = jid.includes("@lid")
? `${jid.split("@")[0]}@s.whatsapp.net`
const jidPresencia = jid.includes('@lid')
? `${jid.split('@')[0]}@s.whatsapp.net`
: jid;
await this.sock.sendPresenceUpdate("composing", jidPresencia);
await this.sock.sendPresenceUpdate('composing', jidPresencia);
await this.delay(this.calcularDelayEscritura(texto.length));
await this.sock.sendPresenceUpdate("paused", jidPresencia);
await this.sock.sendPresenceUpdate('paused', jidPresencia);
const safeSock = wrapSocket(this.sock);
await safeSock.sendMessage(jid, { text: texto });
this.logger.log(`Mensaje enviado a ${jid}`);
} catch (error) {
} catch (error: any) {
this.logger.error(`Error enviando mensaje a ${jid}: ${error.message}`);
}
}
async enviarApertura(
telefono: string,
mensajeApertura: string,
): Promise<void> {
const jid = `${telefono}@s.whatsapp.net`;
await this.enviarMensaje(jid, mensajeApertura);
}
isConectado(): boolean {
return this.sock !== null;
}