Logia de agente de Whatsapp

This commit is contained in:
unknown
2026-05-31 22:02:58 -04:00
parent aa7555b49d
commit ef78d9a14c
28 changed files with 12736 additions and 0 deletions

View File

@@ -0,0 +1,252 @@
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
WASocket,
downloadMediaMessage,
proto,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import * as path from 'path';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pino = require('pino');
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 { Lead } from '../leads/lead.entity';
@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');
constructor(
private readonly leadsService: LeadsService,
private readonly conversacionService: ConversacionService,
private readonly claudeService: ClaudeService,
private readonly mediaService: MediaService,
) {}
async onModuleInit() {
await this.conectar();
}
async onModuleDestroy() {
if (this.sock) {
this.sock.end(undefined);
}
}
private async conectar() {
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
this.sock = makeWASocket({
auth: state,
printQRInTerminal: true,
logger: pino({ level: 'silent' }) as any,
});
this.sock.ev.on('creds.update', saveCreds);
this.sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
console.log('\n📲 Escanea el QR de arriba con WhatsApp\n');
}
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 está lista para recibir mensajes.',
);
}
});
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
if (type !== 'notify') return;
for (const msg of messages) {
if (msg.key.fromMe) continue; // ignorar mensajes propios
if (!msg.key.remoteJid) continue;
await this.procesarMensaje(msg);
}
});
}
/**
* Procesa un mensaje entrante de WhatsApp.
* Identifica el tipo (texto, audio, imagen), normaliza el contenido,
* consulta/crea el lead, llama a Claude y envía la respuesta.
*/
private async procesarMensaje(
msg: proto.IWebMessageInfo,
): Promise<void> {
const jid = msg.key.remoteJid!;
// Normalizar el número de teléfono (quitar el @s.whatsapp.net y el sufijo de grupo)
const telefono = jid.replace('@s.whatsapp.net', '').replace('@g.us', '');
try {
// 1. Identificar o crear el lead
const lead = await this.leadsService.findOrCreate(telefono);
// Ignorar leads ya terminados
if (['completado', 'no_viable', 'perdido'].includes(lead.estado_actual)) {
this.logger.log(
`Lead id=${lead.id} en estado=${lead.estado_actual}. Mensaje ignorado.`,
);
return;
}
// 2. Determinar el tipo de mensaje y normalizarlo a texto
let textoNormalizado = '';
const msgContent = msg.message;
if (!msgContent) return;
if (msgContent.conversation || msgContent.extendedTextMessage) {
// Texto plano
textoNormalizado =
msgContent.conversation ||
msgContent.extendedTextMessage?.text ||
'';
} else if (msgContent.audioMessage) {
// Audio → Claude transcripción
this.logger.log(`Audio recibido de lead id=${lead.id}. Transcribiendo...`);
const buffer = await downloadMediaMessage(msg, 'buffer', {});
const mimeType =
msgContent.audioMessage.mimetype || 'audio/ogg; codecs=opus';
textoNormalizado = await this.mediaService.transcribirAudio(
buffer as Buffer,
mimeType,
);
} else if (msgContent.imageMessage) {
// Imagen → Claude Vision
this.logger.log(
`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`,
);
const buffer = await downloadMediaMessage(msg, 'buffer', {});
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
textoNormalizado = await this.mediaService.inferirImagen(
buffer as Buffer,
mimeType,
lead.estado_actual,
);
// Si el lead envió un caption junto con la imagen, concatenarlo
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.`,
);
return;
}
if (!textoNormalizado.trim()) return;
// 3. Guardar el mensaje del usuario en historial
await this.conversacionService.guardarMensaje(
lead.id,
'user',
textoNormalizado,
);
// 4. Construir historial y llamar a Claude
const historial =
await this.conversacionService.obtenerHistorialComoMessages(lead.id);
const { respuesta, entidad, viable } = await this.claudeService.llamarClaude(
lead,
historial.slice(0, -1), // el último ya es el mensaje actual
textoNormalizado,
);
// 5. Actualizar datos del lead con lo extraído por Claude
if (entidad && Object.keys(entidad).length > 0) {
await this.leadsService.updateDatos(lead.id, entidad);
}
// 6. Manejar el flag viable
if (viable !== undefined && viable !== null) {
await this.leadsService.marcarViable(lead, viable);
this.logger.log(
`Lead id=${lead.id} marcado como viable=${viable}`,
);
} else {
// Avanzar estado si sigue en_proceso
if (lead.estado_actual === 'nuevo') {
await this.leadsService.updateEstado(lead, 'en_proceso');
}
}
// 7. Guardar respuesta de Claude en historial
await this.conversacionService.guardarMensaje(
lead.id,
'assistant',
respuesta,
);
// 8. Enviar respuesta por WhatsApp
await this.enviarMensaje(jid, respuesta);
} catch (error) {
this.logger.error(
`Error procesando mensaje de ${telefono}: ${error.message}`,
error.stack,
);
}
}
/**
* Envía un mensaje de texto por WhatsApp.
*/
async enviarMensaje(jid: string, texto: string): Promise<void> {
if (!this.sock) {
this.logger.error('Socket de WhatsApp no disponible');
return;
}
try {
await this.sock.sendMessage(jid, { text: texto });
this.logger.log(`Mensaje enviado a ${jid}`);
} catch (error) {
this.logger.error(`Error enviando mensaje a ${jid}: ${error.message}`);
}
}
/**
* Envía el mensaje de apertura de Luisa a un número de teléfono.
* Lo usa el Scheduler para disparar el primer contacto.
*/
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;
}
}