Configurcion de personalidad

This commit is contained in:
unknown
2026-06-02 23:07:34 -04:00
parent 7b0eb31f56
commit fc6a7044b0
8 changed files with 346 additions and 94 deletions

View File

@@ -2,6 +2,7 @@ OPENROUTER_API_KEY=
MODEL_GENERADOR=anthropic/claude-sonnet-4-5 MODEL_GENERADOR=anthropic/claude-sonnet-4-5
MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5 MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5
MODEL_REGLAS=anthropic/claude-haiku-4-5 MODEL_REGLAS=anthropic/claude-haiku-4-5
MODEL_TRANSCRIPCION=google/gemini-2.5-flash
MODEL= MODEL=
DATABASE_URL= DATABASE_URL=
ALLOWED_NUMBER= ALLOWED_NUMBER=

View File

@@ -18,7 +18,7 @@ Maximo 2 reintentos; al tercero:
## Media ## Media
**Audio:** Claude lo transcribe y trata como texto; si no entiende: "No te escuche bien, puedes repetirlo?" **Audio:** Transcribe y trata como texto; conserva coloquialismos y jerga del usuario (Madrid/Espana). Si no entiende: "No te he oido bien, me lo repites?"
**Imagen en ESPACIO o TAMANO:** infiere el espacio y los m2 aproximados de la foto y usalo como respuesta al estado actual. **Imagen en ESPACIO o TAMANO:** infiere el espacio y los m2 aproximados de la foto y usalo como respuesta al estado actual.
@@ -30,7 +30,7 @@ Maximo 2 reintentos; al tercero:
## Tono defensivo o brusco ## Tono defensivo o brusco
No te disculpes; no te alteres. Sigue con la siguiente pregunta del flujo de forma tranquila y natural. No te disculpes; no te alteres. Sigue con la siguiente pregunta del flujo de forma tranquila y natural. Si suelta jerga o va directo al grano, tú también puedes ser breve y cercana, sin sonar corporativa.
## Usuario que no quiere dar el presupuesto ## Usuario que no quiere dar el presupuesto

View File

@@ -24,6 +24,15 @@ Si el usuario te llama “Luisa”, responde afirmativamente y continúa con el
- **Nunca** uses estas palabras: *perfecto, excelente, por supuesto, encantada, claro que sí, genial*. - **Nunca** uses estas palabras: *perfecto, excelente, por supuesto, encantada, claro que sí, genial*.
- **Nunca** hagas dos preguntas en un mismo mensaje. - **Nunca** hagas dos preguntas en un mismo mensaje.
### Español de Madrid y conexión local
- Tus usuarios están en **Madrid y España**. Hablas **español peninsular**, nunca latinoamericanismos forzados ni español neutro de manual.
- Suena como alguien de Madrid en WhatsApp: cercana, directa, de confianza.
- Puedes usar expresiones coloquiales **suaves y naturales** cuando encaje: *vale, mira, oye, venga, claro* — sin caricatura ni exceso de jerga.
- **Adapta el registro al usuario**: si escribe o habla coloquial, acércate a su tono; si es más formal, mantén profesionalidad sin ser distante.
- Si el usuario usa jerga madrileña o muletillas (*tío/tía, molar, flipar, hostia suave, etc.*), **no te choques**: entiende la intención y responde con naturalidad, sin corregirle ni sermonear.
- Nunca imites acento por escrito ni forces modismos en cada mensaje; la naturalidad manda.
--- ---
## 2. MÁQUINA DE ESTADOS (FLUJO OBLIGATORIO) ## 2. MÁQUINA DE ESTADOS (FLUJO OBLIGATORIO)

View File

@@ -6,9 +6,10 @@ import { Lead } from '../leads/lead.entity';
import { LeadsService } from '../leads/leads.service'; import { LeadsService } from '../leads/leads.service';
const DEFAULT_SYSTEM_PROMPT = const DEFAULT_SYSTEM_PROMPT =
'Eres Luisa, asesora de Reformix. Cualificas leads de reforma por WhatsApp. ' + 'Eres Luisa, asesora de Reformix en Madrid. Cualificas leads de reforma por WhatsApp. ' +
'Eres cercana, directa y profesional. Un mensaje por turno, maximo 2 lineas. ' + 'Hablas espanol de Espana, cercana y natural; adapta el registro al usuario (coloquial si el, formal si el). ' +
'Sin emojis, sin guiones largos. NUNCA digas que eres IA, ChatGPT, OpenAI ni un asistente virtual.'; 'Un mensaje por turno, maximo 2 lineas. Sin emojis, sin guiones largos. ' +
'NUNCA digas que eres IA, ChatGPT, OpenAI ni un asistente virtual.';
const FRASES_IA_PROHIBIDAS = [ const FRASES_IA_PROHIBIDAS = [
/soy un (modelo|asistente)/i, /soy un (modelo|asistente)/i,
@@ -253,7 +254,9 @@ Reglas para valor_extraido:
- presupuesto: numero o rango en euros tal como lo dijo el usuario - presupuesto: numero o rango en euros tal como lo dijo el usuario
- apertura: null si solo confirma disponibilidad; extrae nombre si lo menciona - apertura: null si solo confirma disponibilidad; extrae nombre si lo menciona
- Si el usuario pregunta algo (nombre, precios, etc.) usa intencion "pregunta" y responde_pregunta false - Si el usuario pregunta algo (nombre, precios, etc.) usa intencion "pregunta" y responde_pregunta false
- Saludos casuales sin confirmar disponibilidad: intencion "desvio", es_desvio true`; - Saludos casuales sin confirmar disponibilidad: intencion "desvio", es_desvio true
- Usuarios de Madrid/Espana: interpreta coloquialismos, jerga y dialecto peninsular (vale, tio, mola, guay, etc.) como respuesta valida si aportan el dato del estado
- Extrae el valor semantico aunque venga en lenguaje coloquial ("pa la cocina" -> espacio cocina, "unos 15 mil" -> presupuesto)`;
const intentos = [ const intentos = [
{ jsonMode: true, temperature: 0.1 }, { jsonMode: true, temperature: 0.1 },
@@ -405,7 +408,8 @@ ${this.serializarLead(lead)}
- Forzar mensaje de apertura: ${forzarApertura} - Forzar mensaje de apertura: ${forzarApertura}
## Instrucciones de respuesta ## Instrucciones de respuesta
Eres Luisa de Reformix. Sigue el system prompt al pie de la letra. Eres Luisa de Reformix en Madrid. Sigue el system prompt al pie de la letra.
Habla espanol de Espana; suena natural y cercana. Adapta el registro al usuario (coloquial si el, formal si el).
Devuelve SOLO el texto del mensaje a enviar por WhatsApp. Devuelve SOLO el texto del mensaje a enviar por WhatsApp.
NO incluyas JSON, etiquetas XML ni bloques de datos extraidos. NO incluyas JSON, etiquetas XML ni bloques de datos extraidos.
NUNCA digas que eres IA, OpenAI, ChatGPT ni un asistente virtual. NUNCA digas que eres IA, OpenAI, ChatGPT ni un asistente virtual.
@@ -456,7 +460,8 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
- Intencion del usuario: ${clasificacion.intencion} - Intencion del usuario: ${clasificacion.intencion}
## Reglas de correccion obligatorias ## Reglas de correccion obligatorias
- Debe sonar como Luisa de Reformix, nunca como un asistente generico - Debe sonar como Luisa de Reformix (Madrid), nunca como un asistente generico
- Espanol de Espana, natural; puede usar coloquialismos suaves (vale, mira, oye) si encaja con el tono del usuario
- Maximo 2 lineas, un mensaje por turno, sin emojis, sin guiones largos - Maximo 2 lineas, un mensaje por turno, sin emojis, sin guiones largos
- NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales - NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales
- Si preguntan el nombre: "Soy Luisa de Reformix" - Si preguntan el nombre: "Soy Luisa de Reformix"

View File

@@ -1,82 +1,188 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from "@nestjs/common";
import axios from 'axios'; import axios from "axios";
import { EstadoLead } from '../leads/lead.entity'; import { EstadoLead } from "../leads/lead.entity";
@Injectable() @Injectable()
export class MediaService { export class MediaService {
private readonly logger = new Logger(MediaService.name); private readonly logger = new Logger(MediaService.name);
private readonly OPENROUTER_URL = private readonly OPENROUTER_URL =
'https://openrouter.ai/api/v1/chat/completions'; "https://openrouter.ai/api/v1/chat/completions";
private get headers() { private get headers() {
return { return {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json', "Content-Type": "application/json",
'HTTP-Referer': 'https://reformix.es', "HTTP-Referer": "https://reformix.es",
'X-Title': 'Reformix Luisa Bot', "X-Title": "Reformix Luisa Bot",
}; };
} }
private getModeloTranscripcion(): string {
return (
process.env.MODEL_TRANSCRIPCION || "google/gemini-2.5-flash"
);
}
/** /**
* Transcribe un audio enviándolo a Claude 4.5 como base64. * Convierte mimetype de WhatsApp al formato que espera OpenRouter input_audio.
* Baileys entrega el buffer del audio; lo convertimos a base64. */
* mimeToAudioFormat(mimeType: string): string {
* @param audioBuffer Buffer del audio recibido por Baileys const base = mimeType.toLowerCase().split(";")[0].trim();
* @param mimeType MIME type del audio (ej: audio/ogg; codecs=opus) const map: Record<string, string> = {
* @returns Texto transcrito, o el fallback si falla "audio/ogg": "ogg",
"audio/opus": "ogg",
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/mp4": "m4a",
"audio/aac": "aac",
"audio/wav": "wav",
"audio/webm": "webm",
"audio/flac": "flac",
};
return map[base] ?? "ogg";
}
/**
* Elimina encabezados y formato que el modelo pueda añadir a la transcripcion.
*/
limpiarTranscripcion(texto: string): string {
return texto
.replace(/^#+\s*transcripci[oó]n\s*:?\s*\n?/gim, "")
.replace(/^\*\*transcripci[oó]n\*\*\s*:?\s*\n?/gim, "")
.replace(/^transcripci[oó]n\s*:?\s*\n?/gim, "")
.replace(/^```[\s\S]*?\n/g, "")
.replace(/\n```$/g, "")
.replace(/^["']|["']$/g, "")
.trim();
}
private detectarFormatoPorMagicBytes(buffer: Buffer): string | null {
if (
buffer.length >= 4 &&
buffer.subarray(0, 4).toString("ascii") === "OggS"
) {
return "ogg";
}
if (
buffer.length >= 3 &&
buffer[0] === 0xff &&
(buffer[1] & 0xe0) === 0xe0
) {
return "mp3";
}
if (
buffer.length >= 12 &&
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
buffer.subarray(8, 12).toString("ascii") === "WAVE"
) {
return "wav";
}
return null;
}
/**
* Transcribe un audio via OpenRouter input_audio (Gemini por defecto).
* Claude no soporta audio en OpenRouter; Luisa sigue usando Claude en el resto del pipeline.
*/ */
async transcribirAudio( async transcribirAudio(
audioBuffer: Buffer, audioBuffer: Buffer,
mimeType = 'audio/ogg', mimeType = "audio/ogg; codecs=opus",
): Promise<string> { ): Promise<string> {
const FALLBACK = const FALLBACK =
'No pude escuchar bien el audio. ¿Puedes escribirme lo que me querías contar?'; "No te he oido bien, me lo repites?";
const formatFromMime = this.mimeToAudioFormat(mimeType);
const formatFromMagic = this.detectarFormatoPorMagicBytes(audioBuffer);
const format = formatFromMagic ?? formatFromMime;
const base64Audio = audioBuffer.toString("base64");
const model = this.getModeloTranscripcion();
this.logger.log(
`[AUDIO 2/4] MediaService.transcribirAudio — buffer=${audioBuffer.length} bytes, mime=${mimeType}, format=${format}, magic=${formatFromMagic ?? "no detectado"}, base64=${base64Audio.length} chars, modelo=${model}`,
);
if (audioBuffer.length < 100) {
this.logger.warn(
`[AUDIO 2/4] Buffer demasiado pequeno (${audioBuffer.length} bytes), abortando transcripcion`,
);
return FALLBACK;
}
const systemPrompt =
"Eres un transcriptor de voz para usuarios de Madrid y Espana. " +
"Transcribe en espanol peninsular tal como se habla, conservando coloquialismos, " +
"muletillas y jerga (vale, tio, guay, mola, etc.) sin corregir ni formalizar. " +
"Responde unicamente con las palabras dichas, sin titulos, markdown, comillas ni explicaciones.";
const userPrompt =
"Transcribe exactamente lo que dice la persona en este audio. " +
"Es espanol de Espana, posiblemente con tono coloquial madrileño. " +
"Devuelve solo las palabras habladas, tal cual, nada mas.";
try { try {
const base64Audio = audioBuffer.toString('base64'); const payload = {
model,
const response = await axios.post(
this.OPENROUTER_URL,
{
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
messages: [ messages: [
{ role: "system", content: systemPrompt },
{ {
role: 'user', role: "user",
content: [ content: [
{ type: "text", text: userPrompt },
{ {
type: 'text', type: "input_audio",
text: 'Por favor, transcribe exactamente lo que se dice en este audio. Devuelve solo la transcripción, sin añadir nada más.', input_audio: {
}, data: base64Audio,
{ format,
type: 'image_url', // OpenRouter usa image_url para base64 de audio también
image_url: {
url: `data:${mimeType};base64,${base64Audio}`,
}, },
}, },
], ],
}, },
], ],
max_tokens: 512, max_tokens: 512,
}, temperature: 0,
{ headers: this.headers }, };
this.logger.debug(
`[AUDIO 3/4] Enviando a OpenRouter — endpoint=${this.OPENROUTER_URL}, content_type=input_audio, format=${format}`,
); );
const transcripcion: string = const response = await axios.post(this.OPENROUTER_URL, payload, {
response.data.choices?.[0]?.message?.content?.trim(); headers: this.headers,
});
if (!transcripcion) { const raw: string =
this.logger.warn('Claude devolvió respuesta vacía para el audio'); response.data.choices?.[0]?.message?.content?.trim() ?? "";
const modeloUsado = response.data.model ?? model;
this.logger.log(
`[AUDIO 3/4] Respuesta OpenRouter — modelo=${modeloUsado}, raw_length=${raw.length}, raw_preview="${raw.slice(0, 120).replace(/\n/g, "\\n")}"`,
);
if (!raw) {
this.logger.warn(
"[AUDIO 4/4] Modelo devolvio respuesta vacia para el audio",
);
return FALLBACK; return FALLBACK;
} }
const transcripcion = this.limpiarTranscripcion(raw);
this.logger.log( this.logger.log(
`Audio transcrito correctamente (${transcripcion.length} chars)`, `[AUDIO 4/4] Transcripcion final — length=${transcripcion.length}, texto="${transcripcion.slice(0, 200).replace(/\n/g, "\\n")}"`,
); );
if (!transcripcion) {
this.logger.warn(
"[AUDIO 4/4] Transcripcion vacia tras limpieza, usando fallback",
);
return FALLBACK;
}
return transcripcion; return transcripcion;
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Error transcribiendo audio: ${error.message}`, `[AUDIO 3/4] Error transcribiendo audio: ${error.message}`,
error.response?.data, error.response?.data,
); );
return FALLBACK; return FALLBACK;
@@ -84,58 +190,50 @@ export class MediaService {
} }
/** /**
* Infiere información de una imagen según el estado actual del lead. * Infiere informacion de una imagen segun el estado actual del lead.
* Útil para capturar espacios, materiales, estilos, etc.
*
* @param imagenBuffer Buffer de la imagen recibida por Baileys
* @param mimeType MIME type (ej: image/jpeg)
* @param estadoActual Estado del lead para adaptar el prompt de visión
* @returns Texto inferido, o el fallback si falla
*/ */
async inferirImagen( async inferirImagen(
imagenBuffer: Buffer, imagenBuffer: Buffer,
mimeType = 'image/jpeg', mimeType = "image/jpeg",
estadoActual: EstadoLead = 'en_proceso', estadoActual: EstadoLead = "en_proceso",
): Promise<string> { ): Promise<string> {
const FALLBACK = const FALLBACK =
'Recibí tu imagen pero no pude analizarla bien. ¿Puedes describirme lo que muestra?'; "Recibi tu imagen pero no pude analizarla bien. Puedes describirme lo que muestra?";
const promptPorEstado: Record<string, string> = { const promptPorEstado: Record<string, string> = {
nuevo: nuevo:
'Describe brevemente qué tipo de espacio se ve en esta imagen y sus características principales.', "Describe brevemente que tipo de espacio se ve en esta imagen y sus caracteristicas principales.",
en_proceso: en_proceso:
'Describe el espacio que aparece en la imagen: tipo de habitación, materiales, estado actual, tamaño aproximado.', "Describe el espacio que aparece en la imagen: tipo de habitacion, materiales, estado actual, tamano aproximado.",
recopilando_datos: recopilando_datos:
'Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservación.', "Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservacion.",
completado: completado:
'Describe lo que ves en esta imagen relacionado con reformas o diseño de interiores.', "Describe lo que ves en esta imagen relacionado con reformas o diseno de interiores.",
no_viable: no_viable: "Describe brevemente que muestra esta imagen.",
'Describe brevemente qué muestra esta imagen.', perdido: "Describe brevemente que muestra esta imagen.",
perdido:
'Describe brevemente qué muestra esta imagen.',
}; };
const promptDeVisión = const promptDeVision =
promptPorEstado[estadoActual] || promptPorEstado[estadoActual] ||
'Describe qué ves en esta imagen en el contexto de una reforma de hogar.'; "Describe que ves en esta imagen en el contexto de una reforma de hogar.";
try { try {
const base64Imagen = imagenBuffer.toString('base64'); const base64Imagen = imagenBuffer.toString("base64");
const response = await axios.post( const response = await axios.post(
this.OPENROUTER_URL, this.OPENROUTER_URL,
{ {
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5', model:
process.env.MODEL_GENERADOR ||
process.env.MODEL ||
"anthropic/claude-sonnet-4-5",
messages: [ messages: [
{ {
role: 'user', role: "user",
content: [ content: [
{ type: "text", text: promptDeVision },
{ {
type: 'text', type: "image_url",
text: promptDeVisión,
},
{
type: 'image_url',
image_url: { image_url: {
url: `data:${mimeType};base64,${base64Imagen}`, url: `data:${mimeType};base64,${base64Imagen}`,
}, },
@@ -152,7 +250,7 @@ export class MediaService {
response.data.choices?.[0]?.message?.content?.trim(); response.data.choices?.[0]?.message?.content?.trim();
if (!inferencia) { if (!inferencia) {
this.logger.warn('Claude devolvió respuesta vacía para la imagen'); this.logger.warn("Claude devolvio respuesta vacia para la imagen");
return FALLBACK; return FALLBACK;
} }

View File

@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class WhatsappDebounceService {
private pendingMessages: Map<
string,
{
timer: NodeJS.Timeout;
texts: string[];
}
> = new Map();
private readonly DEBOUNCE_MS = 3000;
/**
* Agrega un mensaje al buffer del usuario.
* @param userId Identificador único del usuario (ej. JID)
* @param messageText Texto del mensaje
* @param callback Función a ejecutar cuando se complete el debounce (recibe el texto combinado)
*/
async add(
userId: string,
messageText: string,
callback: (combinedMessage: string) => Promise<void>,
): Promise<void> {
if (this.pendingMessages.has(userId)) {
const pending = this.pendingMessages.get(userId)!;
clearTimeout(pending.timer);
pending.texts.push(messageText);
pending.timer = setTimeout(async () => {
await this.flush(userId, callback);
}, this.DEBOUNCE_MS);
} else {
this.pendingMessages.set(userId, {
timer: setTimeout(async () => {
await this.flush(userId, callback);
}, this.DEBOUNCE_MS),
texts: [messageText],
});
}
}
private async flush(
userId: string,
callback: (combinedMessage: string) => Promise<void>,
): Promise<void> {
const pending = this.pendingMessages.get(userId);
if (!pending) return;
this.pendingMessages.delete(userId);
const combinedMessage = pending.texts.join(' ');
await callback(combinedMessage);
}
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { WhatsappService } from './whatsapp.service'; import { WhatsappService } from './whatsapp.service';
import { WhatsappDebounceService } from './whatsapp-debounce.service';
import { LeadsModule } from '../leads/leads.module'; import { LeadsModule } from '../leads/leads.module';
import { ConversacionModule } from '../conversacion/conversacion.module'; import { ConversacionModule } from '../conversacion/conversacion.module';
import { ClaudeModule } from '../claude/claude.module'; import { ClaudeModule } from '../claude/claude.module';
@@ -7,7 +8,7 @@ import { MediaModule } from '../media/media.module';
@Module({ @Module({
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule], imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule],
providers: [WhatsappService], providers: [WhatsappService, WhatsappDebounceService],
exports: [WhatsappService], exports: [WhatsappService],
}) })
export class WhatsappModule {} export class WhatsappModule {}

View File

@@ -10,6 +10,7 @@ import makeWASocket, {
fetchLatestBaileysVersion, fetchLatestBaileysVersion,
WASocket, WASocket,
downloadMediaMessage, downloadMediaMessage,
normalizeMessageContent,
} from "@whiskeysockets/baileys"; } from "@whiskeysockets/baileys";
import { Boom } from "@hapi/boom"; import { Boom } from "@hapi/boom";
import * as path from "path"; import * as path from "path";
@@ -19,6 +20,7 @@ import { LeadsService } from "../leads/leads.service";
import { ConversacionService } from "../conversacion/conversacion.service"; import { ConversacionService } from "../conversacion/conversacion.service";
import { ClaudeService } from "../claude/claude.service"; import { ClaudeService } from "../claude/claude.service";
import { MediaService } from "../media/media.service"; import { MediaService } from "../media/media.service";
import { WhatsappDebounceService } from "./whatsapp-debounce.service";
import { wrapSocket } from "baileys-antiban"; import { wrapSocket } from "baileys-antiban";
const ESTADOS_TERMINALES = [ const ESTADOS_TERMINALES = [
@@ -34,12 +36,15 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(WhatsappService.name); private readonly logger = new Logger(WhatsappService.name);
private sock: WASocket | null = null; 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" });
constructor( constructor(
private readonly leadsService: LeadsService, private readonly leadsService: LeadsService,
private readonly conversacionService: ConversacionService, private readonly conversacionService: ConversacionService,
private readonly claudeService: ClaudeService, private readonly claudeService: ClaudeService,
private readonly mediaService: MediaService, private readonly mediaService: MediaService,
private readonly debounceService: WhatsappDebounceService,
) {} ) {}
async onModuleInit() { async onModuleInit() {
@@ -71,11 +76,13 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
const { state, saveCreds } = await useMultiFileAuthState(this.authDir); const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
const { version } = await fetchLatestBaileysVersion(); const { version } = await fetchLatestBaileysVersion();
this.baileysLogger = pino({ level: "info" }) as any;
this.sock = makeWASocket({ this.sock = makeWASocket({
version, version,
auth: state, auth: state,
printQRInTerminal: false, printQRInTerminal: false,
logger: pino({ level: "info" }) as any, logger: this.baileysLogger,
markOnlineOnConnect: false, markOnlineOnConnect: false,
generateHighQualityLinkPreview: false, generateHighQualityLinkPreview: false,
syncFullHistory: false, syncFullHistory: false,
@@ -132,11 +139,49 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
continue; continue;
} }
await this.procesarMensaje(msg); await this.encolarMensaje(msg);
} }
}); });
} }
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 || "";
return texto.trim() ? texto : null;
}
return null;
}
private crearMsgConTexto(msg: any, texto: string): any {
return {
...msg,
message: { conversation: texto },
};
}
private async encolarMensaje(msg: any): Promise<void> {
const jid = msg.key.remoteJid!;
const textoPlano = this.extraerTextoPlano(msg);
if (textoPlano === null) {
await this.procesarMensaje(msg);
return;
}
this.ultimoMsgPorJid.set(jid, msg);
await this.debounceService.add(jid, textoPlano, async (combinedMessage) => {
const baseMsg = this.ultimoMsgPorJid.get(jid) ?? msg;
this.ultimoMsgPorJid.delete(jid);
await this.procesarMensaje(this.crearMsgConTexto(baseMsg, combinedMessage));
});
}
private async procesarMensaje(msg: any): Promise<void> { private async procesarMensaje(msg: any): Promise<void> {
const jid = msg.key.remoteJid!; const jid = msg.key.remoteJid!;
@@ -155,7 +200,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
} }
let textoNormalizado = ""; let textoNormalizado = "";
const msgContent = msg.message; const msgContent = normalizeMessageContent(msg.message);
if (!msgContent) return; if (!msgContent) return;
@@ -163,24 +208,62 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
textoNormalizado = textoNormalizado =
msgContent.conversation || msgContent.extendedTextMessage?.text || ""; msgContent.conversation || msgContent.extendedTextMessage?.text || "";
} else if (msgContent.audioMessage) { } else if (msgContent.audioMessage) {
const audioMeta = msgContent.audioMessage;
const mimeType = audioMeta.mimetype || "audio/ogg; codecs=opus";
this.logger.log( this.logger.log(
`Audio recibido de lead id=${lead.id}. Transcribiendo...`, `[AUDIO 1/4] Recibido lead=${lead.id}, ptt=${audioMeta.ptt ?? false}, seconds=${audioMeta.seconds ?? "?"}, mimetype=${mimeType}, fileLength=${audioMeta.fileLength ?? "?"}, url=${audioMeta.url ? "si" : "no"}`,
); );
const buffer = await downloadMediaMessage(msg as any, "buffer", {});
const mimeType = if (!this.sock) {
msgContent.audioMessage.mimetype || "audio/ogg; codecs=opus"; 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( textoNormalizado = await this.mediaService.transcribirAudio(
buffer as Buffer, audioBuffer,
mimeType, mimeType,
); );
this.logger.log(
`[AUDIO 1/4] Transcripcion recibida en procesarMensaje — "${textoNormalizado.slice(0, 200).replace(/\n/g, "\\n")}"`,
);
} else if (msgContent.imageMessage) { } else if (msgContent.imageMessage) {
this.logger.log( this.logger.log(
`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`, `Imagen recibida de lead id=${lead.id}. Analizando con Vision...`,
); );
const buffer = await downloadMediaMessage(msg as any, "buffer", {});
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 mimeType = msgContent.imageMessage.mimetype || "image/jpeg";
textoNormalizado = await this.mediaService.inferirImagen( textoNormalizado = await this.mediaService.inferirImagen(
buffer as Buffer, Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer),
mimeType, mimeType,
lead.estado_actual, lead.estado_actual,
); );