diff --git a/mvp/Whatsapp-bot/.env.example b/mvp/Whatsapp-bot/.env.example index 82fb3ae..871ceb1 100644 --- a/mvp/Whatsapp-bot/.env.example +++ b/mvp/Whatsapp-bot/.env.example @@ -2,6 +2,7 @@ OPENROUTER_API_KEY= MODEL_GENERADOR=anthropic/claude-sonnet-4-5 MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5 MODEL_REGLAS=anthropic/claude-haiku-4-5 +MODEL_TRANSCRIPCION=google/gemini-2.5-flash MODEL= DATABASE_URL= ALLOWED_NUMBER= diff --git a/mvp/Whatsapp-bot/prompts/luisa_casos.md b/mvp/Whatsapp-bot/prompts/luisa_casos.md index f364f9f..0134071 100644 --- a/mvp/Whatsapp-bot/prompts/luisa_casos.md +++ b/mvp/Whatsapp-bot/prompts/luisa_casos.md @@ -18,7 +18,7 @@ Maximo 2 reintentos; al tercero: ## 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. @@ -30,7 +30,7 @@ Maximo 2 reintentos; al tercero: ## 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 diff --git a/mvp/Whatsapp-bot/prompts/luisa_core.md b/mvp/Whatsapp-bot/prompts/luisa_core.md index d74a79a..d6d30df 100644 --- a/mvp/Whatsapp-bot/prompts/luisa_core.md +++ b/mvp/Whatsapp-bot/prompts/luisa_core.md @@ -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** 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) diff --git a/mvp/Whatsapp-bot/src/claude/claude.service.ts b/mvp/Whatsapp-bot/src/claude/claude.service.ts index 3ae37ee..9cfcd5c 100644 --- a/mvp/Whatsapp-bot/src/claude/claude.service.ts +++ b/mvp/Whatsapp-bot/src/claude/claude.service.ts @@ -6,9 +6,10 @@ import { Lead } from '../leads/lead.entity'; import { LeadsService } from '../leads/leads.service'; const DEFAULT_SYSTEM_PROMPT = - 'Eres Luisa, asesora de Reformix. Cualificas leads de reforma por WhatsApp. ' + - 'Eres cercana, directa y profesional. Un mensaje por turno, maximo 2 lineas. ' + - 'Sin emojis, sin guiones largos. NUNCA digas que eres IA, ChatGPT, OpenAI ni un asistente virtual.'; + 'Eres Luisa, asesora de Reformix en Madrid. Cualificas leads de reforma por WhatsApp. ' + + 'Hablas espanol de Espana, cercana y natural; adapta el registro al usuario (coloquial si el, formal si el). ' + + '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 = [ /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 - 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 -- 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 = [ { jsonMode: true, temperature: 0.1 }, @@ -405,7 +408,8 @@ ${this.serializarLead(lead)} - Forzar mensaje de apertura: ${forzarApertura} ## 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. NO incluyas JSON, etiquetas XML ni bloques de datos extraidos. 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} ## 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 - NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales - Si preguntan el nombre: "Soy Luisa de Reformix" diff --git a/mvp/Whatsapp-bot/src/media/media.service.ts b/mvp/Whatsapp-bot/src/media/media.service.ts index 77c4209..55a599c 100644 --- a/mvp/Whatsapp-bot/src/media/media.service.ts +++ b/mvp/Whatsapp-bot/src/media/media.service.ts @@ -1,82 +1,188 @@ -import { Injectable, Logger } from '@nestjs/common'; -import axios from 'axios'; -import { EstadoLead } from '../leads/lead.entity'; +import { Injectable, Logger } from "@nestjs/common"; +import axios from "axios"; +import { EstadoLead } from "../leads/lead.entity"; @Injectable() export class MediaService { private readonly logger = new Logger(MediaService.name); private readonly OPENROUTER_URL = - 'https://openrouter.ai/api/v1/chat/completions'; + "https://openrouter.ai/api/v1/chat/completions"; private get headers() { return { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'https://reformix.es', - 'X-Title': 'Reformix Luisa Bot', + "Content-Type": "application/json", + "HTTP-Referer": "https://reformix.es", + "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. - * Baileys entrega el buffer del audio; lo convertimos a base64. - * - * @param audioBuffer Buffer del audio recibido por Baileys - * @param mimeType MIME type del audio (ej: audio/ogg; codecs=opus) - * @returns Texto transcrito, o el fallback si falla + * Convierte mimetype de WhatsApp al formato que espera OpenRouter input_audio. + */ + mimeToAudioFormat(mimeType: string): string { + const base = mimeType.toLowerCase().split(";")[0].trim(); + const map: Record = { + "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( audioBuffer: Buffer, - mimeType = 'audio/ogg', + mimeType = "audio/ogg; codecs=opus", ): Promise { 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 { - const base64Audio = audioBuffer.toString('base64'); + const payload = { + model, + messages: [ + { role: "system", content: systemPrompt }, + { + role: "user", + content: [ + { type: "text", text: userPrompt }, + { + type: "input_audio", + input_audio: { + data: base64Audio, + format, + }, + }, + ], + }, + ], + max_tokens: 512, + temperature: 0, + }; - const response = await axios.post( - this.OPENROUTER_URL, - { - model: process.env.MODEL || 'anthropic/claude-sonnet-4-5', - messages: [ - { - role: 'user', - content: [ - { - type: 'text', - text: 'Por favor, transcribe exactamente lo que se dice en este audio. Devuelve solo la transcripción, sin añadir nada más.', - }, - { - type: 'image_url', // OpenRouter usa image_url para base64 de audio también - image_url: { - url: `data:${mimeType};base64,${base64Audio}`, - }, - }, - ], - }, - ], - max_tokens: 512, - }, - { 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 = - response.data.choices?.[0]?.message?.content?.trim(); + const response = await axios.post(this.OPENROUTER_URL, payload, { + headers: this.headers, + }); - if (!transcripcion) { - this.logger.warn('Claude devolvió respuesta vacía para el audio'); + const raw: string = + 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; } + const transcripcion = this.limpiarTranscripcion(raw); + 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; } catch (error) { this.logger.error( - `Error transcribiendo audio: ${error.message}`, + `[AUDIO 3/4] Error transcribiendo audio: ${error.message}`, error.response?.data, ); return FALLBACK; @@ -84,58 +190,50 @@ export class MediaService { } /** - * Infiere información de una imagen según 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 + * Infiere informacion de una imagen segun el estado actual del lead. */ async inferirImagen( imagenBuffer: Buffer, - mimeType = 'image/jpeg', - estadoActual: EstadoLead = 'en_proceso', + mimeType = "image/jpeg", + estadoActual: EstadoLead = "en_proceso", ): Promise { 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 = { 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: - '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: - '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: - 'Describe lo que ves en esta imagen relacionado con reformas o diseño de interiores.', - no_viable: - 'Describe brevemente qué muestra esta imagen.', - perdido: - 'Describe brevemente qué muestra esta imagen.', + "Describe lo que ves en esta imagen relacionado con reformas o diseno de interiores.", + no_viable: "Describe brevemente que muestra esta imagen.", + perdido: "Describe brevemente que muestra esta imagen.", }; - const promptDeVisión = + const promptDeVision = 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 { - const base64Imagen = imagenBuffer.toString('base64'); + const base64Imagen = imagenBuffer.toString("base64"); const response = await axios.post( 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: [ { - role: 'user', + role: "user", content: [ + { type: "text", text: promptDeVision }, { - type: 'text', - text: promptDeVisión, - }, - { - type: 'image_url', + type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Imagen}`, }, @@ -152,7 +250,7 @@ export class MediaService { response.data.choices?.[0]?.message?.content?.trim(); 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; } diff --git a/mvp/Whatsapp-bot/src/whatsapp/whatsapp-debounce.service.ts b/mvp/Whatsapp-bot/src/whatsapp/whatsapp-debounce.service.ts new file mode 100644 index 0000000..3585337 --- /dev/null +++ b/mvp/Whatsapp-bot/src/whatsapp/whatsapp-debounce.service.ts @@ -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, + ): Promise { + 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, + ): Promise { + const pending = this.pendingMessages.get(userId); + if (!pending) return; + + this.pendingMessages.delete(userId); + const combinedMessage = pending.texts.join(' '); + await callback(combinedMessage); + } +} diff --git a/mvp/Whatsapp-bot/src/whatsapp/whatsapp.module.ts b/mvp/Whatsapp-bot/src/whatsapp/whatsapp.module.ts index b083f4f..49ab747 100644 --- a/mvp/Whatsapp-bot/src/whatsapp/whatsapp.module.ts +++ b/mvp/Whatsapp-bot/src/whatsapp/whatsapp.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { WhatsappService } from './whatsapp.service'; +import { WhatsappDebounceService } from './whatsapp-debounce.service'; import { LeadsModule } from '../leads/leads.module'; import { ConversacionModule } from '../conversacion/conversacion.module'; import { ClaudeModule } from '../claude/claude.module'; @@ -7,7 +8,7 @@ import { MediaModule } from '../media/media.module'; @Module({ imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule], - providers: [WhatsappService], + providers: [WhatsappService, WhatsappDebounceService], exports: [WhatsappService], }) export class WhatsappModule {} diff --git a/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts b/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts index 9f3fa87..04ba64e 100644 --- a/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts +++ b/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts @@ -10,6 +10,7 @@ import makeWASocket, { fetchLatestBaileysVersion, WASocket, downloadMediaMessage, + normalizeMessageContent, } from "@whiskeysockets/baileys"; import { Boom } from "@hapi/boom"; import * as path from "path"; @@ -19,6 +20,7 @@ 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"; const ESTADOS_TERMINALES = [ @@ -34,12 +36,15 @@ 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 readonly ultimoMsgPorJid = new Map(); + private baileysLogger = pino({ level: "info" }); constructor( private readonly leadsService: LeadsService, private readonly conversacionService: ConversacionService, private readonly claudeService: ClaudeService, private readonly mediaService: MediaService, + private readonly debounceService: WhatsappDebounceService, ) {} async onModuleInit() { @@ -71,11 +76,13 @@ 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.sock = makeWASocket({ version, auth: state, printQRInTerminal: false, - logger: pino({ level: "info" }) as any, + logger: this.baileysLogger, markOnlineOnConnect: false, generateHighQualityLinkPreview: false, syncFullHistory: false, @@ -132,11 +139,49 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { 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 { + 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 { const jid = msg.key.remoteJid!; @@ -155,7 +200,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { } let textoNormalizado = ""; - const msgContent = msg.message; + const msgContent = normalizeMessageContent(msg.message); if (!msgContent) return; @@ -163,24 +208,62 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { 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 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 = - msgContent.audioMessage.mimetype || "audio/ogg; codecs=opus"; + + 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( - buffer as Buffer, + 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 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"; textoNormalizado = await this.mediaService.inferirImagen( - buffer as Buffer, + Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer), mimeType, lead.estado_actual, );