Configurcion de personalidad
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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<string> {
|
||||
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<string> {
|
||||
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> = {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
55
mvp/Whatsapp-bot/src/whatsapp/whatsapp-debounce.service.ts
Normal file
55
mvp/Whatsapp-bot/src/whatsapp/whatsapp-debounce.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<string, any>();
|
||||
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<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> {
|
||||
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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user