Compare commits

..

6 Commits

Author SHA1 Message Date
Carlos Narro
4aa0582f53 Merge branch 'main' of https://github.com/McGregory99/reformix-hackaton 2026-06-03 09:52:14 +02:00
Carlos Narro
d2d42ff1d4 Añade imágenes de ejemplo para galería y demostración
Incluye una imagen WebP optimizada (48caf530-fa5f-476b-8120-195cfce7a9ec.webp) y una imagen PNG de ejemplo de reformas (reformas-ejemplo.png) para usar en la galería de trabajos y demostraciones del funnel.
2026-06-03 09:52:10 +02:00
unknown
e5c8956b64 Configuracion para guardar en base de datos 2026-06-02 23:11:46 -04:00
unknown
fc6a7044b0 Configurcion de personalidad 2026-06-02 23:07:34 -04:00
unknown
7b0eb31f56 Merge branch 'main' of https://github.com/McGregory99/reformix-hackaton 2026-06-02 22:49:18 -04:00
unknown
9c5e6bc7fa Configuracion de prompt Luisa 2026-06-02 22:48:59 -04:00
14 changed files with 1248 additions and 288 deletions

View File

@@ -1,3 +1,8 @@
OPENROUTER_API_KEY= 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= MODEL=
DATABASE_URL= DATABASE_URL=
ALLOWED_NUMBER=

View File

@@ -1,20 +1,24 @@
# Luisa — Casos edge # Luisa — Casos edge
## Desvio del flujo ## Desvio del flujo
El usuario pregunta algo fuera del estado actual: El usuario pregunta algo fuera del estado actual:
"Cuando terminemos te cuento todo con detalle. Seguimos?" "Cuando terminemos te cuento todo con detalle. Seguimos?"
## Reintentos ## Reintentos
Si la respuesta no es valida, reformula la misma pregunta con opciones concretas. Si la respuesta no es valida, reformula la misma pregunta con opciones concretas.
Maximo 2 reintentos; al tercero: Maximo 2 reintentos; al tercero:
"Cerramos por ahora; cuando estes listo aqui estamos." "Cerramos por ahora; cuando estes listo aqui estamos."
## Inactividad ## Inactividad
- 24h sin respuesta: "Hola [nombre], quedamos a medias; cuando quieras seguimos con tu presupuesto." - 24h sin respuesta: "Hola [nombre], quedamos a medias; cuando quieras seguimos con tu presupuesto."
- 48h sin respuesta: cerrar con estado perdido, no enviar mensaje. - 48h sin respuesta: cerrar con estado perdido, no enviar mensaje.
## 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.
@@ -25,7 +29,9 @@ Maximo 2 reintentos; al tercero:
**Sticker u otro:** ignora el contenido y usa el mensaje de desvio. **Sticker u otro:** ignora el contenido y usa el mensaje de desvio.
## 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
"No te preocupes; un rango aproximado esta bien, menos de 10.000, entre 10 y 30, o mas?"
"No te preocupes; un rango aproximado esta bien, menos de 10.000, entre 10 y 30, o mas?"

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

@@ -1,32 +1,57 @@
# Luisa — Flujo y estados # Luisa — Flujo y estados
## Maquina de estados ## Maquina de estados
NUEVO -> APERTURA -> ESPACIO -> TAMANO -> ESTILO -> URGENCIA -> PRESUPUESTO -> FIN NUEVO -> APERTURA -> ESPACIO -> TAMANO -> ESTILO -> URGENCIA -> PRESUPUESTO -> FIN
## Datos a recolectar ## Datos a recolectar
| Estado | Campo DB | Valores validos |
|-------------|----------------------|----------------------------------------| | Estado | Campo DB | Valores validos |
| ESPACIO | espacio | cocina, bano, salon, integral, otro | | ----------- | --------------------- | ----------------------------------- |
| TAMANO | rango_m2 | menos10, 10a20, 20a40, mas40 | | ESPACIO | espacio | cocina, bano, salon, integral, otro |
| ESTILO | estilo | funcional, cuidado, exclusivo | | TAMANO | rango_m2 | menos10, 10a20, 20a40, mas40 |
| URGENCIA | urgencia | urgente, medio_plazo, frio | | ESTILO | estilo | funcional, cuidado, exclusivo |
| PRESUPUESTO | presupuesto_declarado| cifra o rango en euros | | URGENCIA | urgencia | urgente, medio_plazo, frio |
| PRESUPUESTO | presupuesto_declarado | cifra o rango en euros |
## Mensajes por estado ## Mensajes por estado
**APERTURA:** "Hola [nombre], soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?"
**ESPACIO:** "Que espacio tienes en mente, cocina, bano, salon, o algo mas completo?" **APERTURA:** "Hola, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto.
**TAMANO:** "Tienes idea del tamano aproximado, menos de 10m2, entre 10 y 20, entre 20 y 40, o mas de 40?" Tienes unos minutos ahora?"
**ESTILO:** "Como te imaginas el resultado; algo funcional y limpio, un acabado mas cuidado con buenos materiales, o algo mas exclusivo donde cada detalle cuenta?" **ESPACIO:** "Que espacio tienes en mente.
**URGENCIA:** "Y cuando tienes pensado arrancar, es algo proximo o todavia estas explorando?" Cocina, bano, salon, o algo mas completo?"
**PRESUPUESTO:** "Ultima pregunta; tienes en mente un presupuesto aproximado para la reforma?" **TAMANO:** "Tienes idea del tamano aproximado.
**FIN_VIABLE:** "Con todo esto ya preparo tu presupuesto. En un momento lo recibes aqui mismo." Menos de 10m2, entre 10 y 20, entre 20 y 40, o mas de 40?"
**FIN_NO_VIABLE:** "Gracias por tu tiempo [nombre]; ahora mismo no podriamos darte el resultado que mereces con ese presupuesto. Si en algun momento cambia, aqui estamos." **ESTILO:** "Como te imaginas el resultado.
**SEGUIMIENTO FASE 3:** "Hola [nombre], te llego bien el presupuesto; quedaste con alguna duda?" Algo funcional y limpio, un acabado mas cuidado con buenos materiales, o algo mas exclusivo donde cada detalle cuenta?"
**URGENCIA:** "Y cuando tienes pensado arrancar.
Es algo proximo o todavia estas explorando?"
**PRESUPUESTO:** "Ultima pregunta.
Tienes en mente un presupuesto aproximado para la reforma?"
**FIN_VIABLE:** "Con todo esto ya preparo tu presupuesto.
En un momento lo recibes aqui mismo."
**FIN_NO_VIABLE:** "Gracias por tu tiempo; ahora mismo no podriamos darte el resultado que mereces con ese presupuesto.
Si en algun momento cambia, aqui estamos."
**DESVIO:** "Cuando terminemos te cuento todo con detalle.
Seguimos?"
**SEGUIMIENTO FASE 3:** "Hola, te llego bien el presupuesto.
Quedaste con alguna duda?"

View File

@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ClaudeService } from './claude.service'; import { ClaudeService } from './claude.service';
import { LeadsModule } from '../leads/leads.module';
@Module({ @Module({
imports: [LeadsModule],
providers: [ClaudeService], providers: [ClaudeService],
exports: [ClaudeService], exports: [ClaudeService],
}) })

View File

@@ -1,33 +1,81 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import axios from 'axios'; import axios from 'axios';
import { Lead } from '../leads/lead.entity'; import { Lead } from '../leads/lead.entity';
import { Conversacion } from '../conversacion/conversacion.entity'; import { LeadsService } from '../leads/leads.service';
const DEFAULT_SYSTEM_PROMPT =
'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,
/desarrollado por openai/i,
/\bopenai\b/i,
/\bchatgpt\b/i,
/inteligencia artificial/i,
/no tengo un nombre propio/i,
/en qu[eé] puedo ayudarte/i,
];
export interface ClasificacionResultado {
responde_pregunta: boolean;
valor_extraido: string | null;
es_desvio: boolean;
intencion: 'respuesta' | 'desvio' | 'despedida' | 'insulto' | 'pregunta';
}
export interface ValidacionResultado {
valido: boolean;
valorNormalizado: string | null;
viable?: boolean;
}
export interface ClaudeResponse { export interface ClaudeResponse {
respuesta: string; respuesta: string;
entidad?: Partial<Lead>; // datos extraídos del turno entidad?: Partial<Lead>;
viable?: boolean; // flag si Claude decide el resultado final viable?: boolean;
nuevoEstado?: string;
} }
@Injectable() @Injectable()
export class ClaudeService { export class ClaudeService implements OnModuleInit {
private readonly logger = new Logger(ClaudeService.name); private readonly logger = new Logger(ClaudeService.name);
private readonly promptsDir = path.join(process.cwd(), 'prompts'); private readonly promptsDir = path.join(process.cwd(), 'prompts');
private systemPromptCache = '';
private reglasPromptCache = '';
private readonly reintentosPorLead = new Map<
string,
{ estado: string; count: number }
>();
/** constructor(private readonly leadsService: LeadsService) {}
* Lee y concatena los 3 archivos MD de /prompts como system prompt.
*/ onModuleInit() {
private leerPromptsSistema(): string { this.systemPromptCache = this.cargarPrompts([
const archivos = ['luisa_core.md', 'luisa_flujo.md', 'luisa_casos.md']; 'luisa_core.md',
'luisa_flujo.md',
'luisa_casos.md',
]);
this.reglasPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_casos.md']);
this.logger.log(
`Prompts cargados: system=${this.systemPromptCache.length} chars, reglas=${this.reglasPromptCache.length} chars`,
);
}
private cargarPrompts(archivos: string[]): string {
const partes: string[] = []; const partes: string[] = [];
for (const archivo of archivos) { for (const archivo of archivos) {
const rutaCompleta = path.join(this.promptsDir, archivo); const rutaCompleta = path.join(this.promptsDir, archivo);
try { try {
if (!fs.existsSync(rutaCompleta)) {
this.logger.warn(`Prompt no encontrado: ${archivo}`);
continue;
}
const contenido = fs.readFileSync(rutaCompleta, 'utf-8'); const contenido = fs.readFileSync(rutaCompleta, 'utf-8');
if (contenido.trim()) { if (contenido.trim()) {
partes.push(`\n\n## ${archivo}\n${contenido}`); partes.push(`\n\n## ${archivo}\n${contenido}`);
@@ -37,12 +85,34 @@ export class ClaudeService {
} }
} }
return partes.join('\n'); const concatenado = partes.join('\n').trim();
return concatenado || DEFAULT_SYSTEM_PROMPT;
}
private leerPromptsSistema(): string {
return this.systemPromptCache || DEFAULT_SYSTEM_PROMPT;
}
private leerPromptsReglas(): string {
return this.reglasPromptCache || DEFAULT_SYSTEM_PROMPT;
}
private getModelo(clave: 'clasificador' | 'generador' | 'reglas'): string {
const defaults = {
clasificador: 'anthropic/claude-haiku-4-5',
generador: 'anthropic/claude-sonnet-4-5',
reglas: 'anthropic/claude-haiku-4-5',
};
const envMap = {
clasificador: process.env.MODEL_CLASIFICADOR,
generador: process.env.MODEL_GENERADOR || process.env.MODEL,
reglas: process.env.MODEL_REGLAS || process.env.MODEL_CLASIFICADOR,
};
return envMap[clave] || defaults[clave];
} }
/**
* Serializa los datos actuales del lead para el contexto de Claude.
*/
private serializarLead(lead: Lead): string { private serializarLead(lead: Lead): string {
return [ return [
`- ID: ${lead.id}`, `- ID: ${lead.id}`,
@@ -60,130 +130,547 @@ export class ClaudeService {
} }
/** /**
* Llama a Claude 4.5 via OpenRouter con el contexto completo del lead. * OpenRouter requiere system dentro de messages[] para modelos OpenAI.
* Devuelve la respuesta de Luisa y los datos extraídos del turno. * El campo system en la raiz del payload no siempre se aplica.
*
* @param lead El lead actual con sus datos en DB
* @param historial Historial de conversación [{role, content}]
* @param mensajeActual El mensaje del usuario (ya puede venir transcrito/inferido)
*/ */
async llamarClaude( private async llamarOpenRouter(
model: string,
system: string,
messages: Array<{ role: string; content: string }>,
options: { temperature?: number; jsonMode?: boolean } = {},
): Promise<string> {
const { temperature = 0.7, jsonMode = false } = options;
const payload: Record<string, unknown> = {
model,
messages: [{ role: 'system', content: system }, ...messages],
max_tokens: 1024,
temperature,
};
if (jsonMode) {
payload.response_format = { type: 'json_object' };
}
const response = await axios.post(
'https://openrouter.ai/api/v1/chat/completions',
payload,
{
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Luisa Bot',
},
},
);
const contenido = response.data.choices?.[0]?.message?.content || '';
const modeloUsado = response.data.model || model;
if (!contenido.trim()) {
this.logger.warn(
`OpenRouter devolvio contenido vacio (modelo=${modeloUsado})`,
);
}
return contenido;
}
private parsearJson<T>(texto: string): T | null {
const limpio = texto
.replace(/```json\s*/gi, '')
.replace(/```\s*/g, '')
.trim();
const inicio = limpio.indexOf('{');
const fin = limpio.lastIndexOf('}');
if (inicio === -1 || fin === -1) return null;
try {
return JSON.parse(limpio.slice(inicio, fin + 1)) as T;
} catch {
return null;
}
}
private normalizarClasificacion(
raw: Partial<ClasificacionResultado>,
): ClasificacionResultado | null {
const intenciones = [
'respuesta',
'desvio',
'despedida',
'insulto',
'pregunta',
] as const;
if (!raw || typeof raw.responde_pregunta !== 'boolean') return null;
const intencion = intenciones.includes(raw.intencion as typeof intenciones[number])
? (raw.intencion as ClasificacionResultado['intencion'])
: 'respuesta';
return {
responde_pregunta: raw.responde_pregunta,
valor_extraido:
raw.valor_extraido === null || raw.valor_extraido === undefined
? null
: String(raw.valor_extraido),
es_desvio: Boolean(raw.es_desvio),
intencion,
};
}
/**
* Capa 1 — Clasificador (Haiku): extrae intencion y valor del mensaje.
*/
private async clasificar(
mensaje: string,
estadoActual: string,
): Promise<ClasificacionResultado> {
const valoresPermitidos = this.leadsService.getValoresPermitidos(estadoActual);
const system = `Eres un clasificador de mensajes para un bot de cualificacion de leads de reformas.
Responde UNICAMENTE con un objeto JSON valido. Sin markdown, sin texto antes ni despues.
Estado actual del lead: ${estadoActual}
Valores permitidos para este estado (si aplica): ${valoresPermitidos.length ? valoresPermitidos.join(', ') : 'ninguno (estado sin valor concreto)'}
Formato exacto:
{
"responde_pregunta": true,
"valor_extraido": null,
"es_desvio": false,
"intencion": "respuesta"
}
Valores validos de intencion: respuesta, desvio, despedida, insulto, pregunta
Reglas para valor_extraido:
- espacio: cocina, bano, salon, integral, otro
- tamano: menos10, 10a20, 20a40, mas40
- estilo: funcional, cuidado, exclusivo
- urgencia: urgente, medio_plazo, frio
- 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
- 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 },
{ jsonMode: true, temperature: 0 },
];
for (const opts of intentos) {
const contenido = await this.llamarOpenRouter(
this.getModelo('clasificador'),
system,
[{ role: 'user', content: mensaje }],
opts,
);
const parsed = this.normalizarClasificacion(
this.parsearJson<Partial<ClasificacionResultado>>(contenido) ?? {},
);
if (parsed) return parsed;
this.logger.warn(
`Clasificador JSON invalido (intento, modelo=${this.getModelo('clasificador')}): ${contenido.slice(0, 200)}`,
);
}
this.logger.warn('Clasificador agotado reintentos, usando fallback conservador');
return {
responde_pregunta: false,
valor_extraido: null,
es_desvio: true,
intencion: 'desvio',
};
}
/**
* Capa 2 — Validador en codigo: valida valor_extraido contra valores permitidos.
*/
private validar(
clasificacion: ClasificacionResultado,
estadoActual: string,
): ValidacionResultado {
const estado = this.leadsService.normalizarEstadoFlujo(estadoActual);
if (
clasificacion.es_desvio ||
clasificacion.intencion === 'desvio' ||
clasificacion.intencion === 'pregunta' ||
clasificacion.intencion === 'insulto' ||
clasificacion.intencion === 'despedida'
) {
return { valido: false, valorNormalizado: null };
}
if (estado === 'nuevo') {
return { valido: false, valorNormalizado: null };
}
if (estado === 'apertura') {
const valido =
clasificacion.responde_pregunta &&
clasificacion.intencion === 'respuesta' &&
!clasificacion.es_desvio;
return { valido, valorNormalizado: clasificacion.valor_extraido };
}
if (estado === 'presupuesto') {
const valor = clasificacion.valor_extraido?.trim();
if (!valor || !this.leadsService.esPresupuestoValido(valor)) {
return { valido: false, valorNormalizado: null };
}
const viable = this.leadsService.evaluarViabilidad(valor);
return { valido: true, valorNormalizado: valor, viable };
}
const valoresPermitidos = this.leadsService.getValoresPermitidos(estado);
const valor = this.normalizarTexto(clasificacion.valor_extraido ?? '');
if (!valor) {
return { valido: false, valorNormalizado: null };
}
const coincide = valoresPermitidos.some(
(v) => v === valor || valor.includes(v) || v.includes(valor),
);
if (!coincide) {
return { valido: false, valorNormalizado: null };
}
const valorNormalizado =
valoresPermitidos.find(
(v) => v === valor || valor.includes(v) || v.includes(valor),
) ?? valor;
return { valido: true, valorNormalizado };
}
private normalizarTexto(valor: string): string {
return valor
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '');
}
private claveReintento(leadId: number, estado: string): string {
return `${leadId}:${estado}`;
}
private obtenerReintentos(leadId: number, estado: string): number {
const clave = this.claveReintento(leadId, estado);
const entry = this.reintentosPorLead.get(clave);
return entry?.estado === estado ? entry.count : 0;
}
private incrementarReintentos(leadId: number, estado: string): number {
const clave = this.claveReintento(leadId, estado);
const actual = this.obtenerReintentos(leadId, estado);
const count = actual + 1;
this.reintentosPorLead.set(clave, { estado, count });
return count;
}
private resetearReintentos(leadId: number, estado: string): void {
this.reintentosPorLead.delete(this.claveReintento(leadId, estado));
}
/**
* Capa 3 — Generador (Sonnet): produce el borrador del mensaje de Luisa.
*/
private async generar(
lead: Lead, lead: Lead,
historial: Array<{ role: string; content: string }>, historial: Array<{ role: string; content: string }>,
mensajeActual: string, mensajeActual: string,
): Promise<ClaudeResponse> { clasificacion: ClasificacionResultado,
validacion: ValidacionResultado,
reintentos: number,
avanzarEstado: boolean,
siguienteEstado: string | null,
forzarApertura = false,
): Promise<string> {
const systemPrompt = this.leerPromptsSistema(); const systemPrompt = this.leerPromptsSistema();
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(
lead.estado_actual,
);
console.log('=== DEBUG SYSTEM PROMPT ==='); const contextoGeneracion = `
console.log('Longitud systemPrompt:', systemPrompt.length); ## Contexto del lead
console.log('Primeros 500 caracteres:', systemPrompt.substring(0, 500));
console.log('=== FIN DEBUG ===');
const contextoDeLead = `
## Contexto del lead actual
${this.serializarLead(lead)} ${this.serializarLead(lead)}
`;
const systemFinal = `${systemPrompt} ## Estado del turno
- Estado de flujo: ${estadoFlujo}
- Clasificacion: ${JSON.stringify(clasificacion)}
- Validacion valida: ${validacion.valido}
- Valor validado: ${validacion.valorNormalizado ?? 'ninguno'}
- Reintentos en este estado: ${reintentos}
- Avanzar estado: ${avanzarEstado}
- Siguiente estado (si avanza): ${siguienteEstado ?? 'sin cambio'}
- Forzar mensaje de apertura: ${forzarApertura}
${contextoDeLead} ## Instrucciones de respuesta
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.
Si preguntan tu nombre, di que eres Luisa de Reformix.
## Instrucciones de extracción de datos Si forzar mensaje de apertura es true, envia el mensaje de APERTURA del flujo.
Si validacion valida es false y reintentos < 2, pide amablemente que aclare su respuesta.
Al responder, incluye al final de tu mensaje un bloque JSON con el formato exacto (sin markdown, sin comillas extras): Si validacion valida es false y reintentos >= 2, repite la pregunta del estado actual de forma directa.
Si es_desvio es true o intencion es pregunta, responde brevemente como Luisa y redirige al flujo sin avanzar.
<DATOS_EXTRAIDOS> Si avanzar estado es true, haz la pregunta correspondiente al siguiente estado.
{ Si el siguiente estado es fin_viable o fin_no_viable, usa el mensaje de cierre correspondiente.`;
"nombre": null,
"email": null,
"espacio": null,
"rango_m2": null,
"estilo": null,
"urgencia": null,
"presupuesto_declarado": null,
"viable": null
}
</DATOS_EXTRAIDOS>
Solo rellena los campos que has capturado en este turno. Los que no hayas capturado déjalos en null.
Si puedes determinar si el lead es viable o no, pon true o false en "viable". Si no, déjalo en null.`;
const messages = [ const messages = [
...historial, ...historial,
{ role: 'user', content: mensajeActual }, { role: 'user', content: mensajeActual },
]; ];
// Construimos el payload para enviar a OpenRouter const contenido = await this.llamarOpenRouter(
const payload = { this.getModelo('generador'),
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5', `${systemPrompt}\n${contextoGeneracion}`,
messages: messages, messages,
system: systemFinal, { temperature: 0.7 },
max_tokens: 1024, );
temperature: 0.7,
return contenido.trim();
}
/**
* Capa 4 — Reglas (Haiku): corrige el borrador para cumplir identidad y tono de Luisa.
*/
private async aplicarReglas(
borrador: string,
lead: Lead,
estadoFlujo: string,
clasificacion: ClasificacionResultado,
): Promise<string> {
const reglas = this.leerPromptsReglas();
const system = `${reglas}
## Tu tarea
Recibes un borrador de respuesta para WhatsApp. Debes corregirlo para que cumpla TODAS las reglas de Luisa.
Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
## Contexto
- Estado del lead: ${estadoFlujo}
- Nombre lead: ${lead.nombre || 'desconocido'}
- Intencion del usuario: ${clasificacion.intencion}
## Reglas de correccion obligatorias
- 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"
- Si el borrador viola alguna regla, reescribelo completamente manteniendo la intencion`;
const contenido = await this.llamarOpenRouter(
this.getModelo('reglas'),
system,
[{ role: 'user', content: `Borrador a corregir:\n${borrador}` }],
{ temperature: 0.3 },
);
return contenido.trim() || borrador;
}
private contieneFraseProhibida(texto: string): boolean {
return FRASES_IA_PROHIBIDAS.some((regex) => regex.test(texto));
}
private mensajeFallback(estadoFlujo: string, lead: Lead): string {
const nombre = lead.nombre ? lead.nombre : '';
const fallbacks: Record<string, string> = {
nuevo: `Hola${nombre ? ' ' + nombre : ''}, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?`,
apertura: `Hola${nombre ? ' ' + nombre : ''}, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?`,
espacio: 'Que espacio tienes en mente, cocina, bano, salon, o algo mas completo?',
tamano: 'Tienes idea del tamano aproximado, menos de 10m2, entre 10 y 20, entre 20 y 40, o mas de 40?',
estilo: 'Como te imaginas el resultado; algo funcional y limpio, un acabado mas cuidado con buenos materiales, o algo mas exclusivo donde cada detalle cuenta?',
urgencia: 'Y cuando tienes pensado arrancar, es algo proximo o todavia estas explorando?',
presupuesto: 'Ultima pregunta; tienes en mente un presupuesto aproximado para la reforma?',
}; };
// LOG DE LO QUE SE ENVÍA (exactamente lo que ve la IA) return (
console.log('\n======= PETICIÓN A OPENROUTER ======='); fallbacks[estadoFlujo] ??
console.log(JSON.stringify(payload, null, 2)); 'Soy Luisa de Reformix; sigamos con tu presupuesto de reforma.'
console.log('=====================================\n'); );
}
try { /**
const response = await axios.post( * Orquesta las 4 capas: clasificar, validar, generar y aplicar reglas.
'https://openrouter.ai/api/v1/chat/completions', */
payload, async llamarClaude(
lead: Lead,
historial: Array<{ role: string; content: string }>,
mensajeActual: string,
): Promise<ClaudeResponse> {
const esAperturaScheduler =
historial.length === 0 && mensajeActual.startsWith('APERTURA:');
if (esAperturaScheduler) {
const borrador = await this.generar(
lead,
historial,
mensajeActual,
{ {
headers: { responde_pregunta: true,
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, valor_extraido: null,
'Content-Type': 'application/json', es_desvio: false,
'HTTP-Referer': 'https://reformix.es', intencion: 'respuesta',
'X-Title': 'Reformix Luisa Bot', },
}, { valido: true, valorNormalizado: null },
0,
false,
'apertura',
true,
);
const respuesta = await this.aplicarReglas(
borrador,
lead,
'apertura',
{
responde_pregunta: true,
valor_extraido: null,
es_desvio: false,
intencion: 'respuesta',
}, },
); );
return {
respuesta: this.contieneFraseProhibida(respuesta)
? this.mensajeFallback('apertura', lead)
: respuesta,
};
}
// LOG DE LA RESPUESTA CRUDA (lo que devuelve OpenRouter) const estadoFlujo = this.leadsService.normalizarEstadoFlujo(
console.log('\n======= RESPUESTA CRUDA DE OPENROUTER ======='); lead.estado_actual,
console.log(JSON.stringify(response.data, null, 2)); );
console.log('=============================================\n');
const contenidoCompleto: string = response.data.choices?.[0]?.message?.content || ''; if (estadoFlujo === 'nuevo') {
const clasificacion: ClasificacionResultado = {
responde_pregunta: false,
valor_extraido: null,
es_desvio: false,
intencion: 'respuesta',
};
const borrador = await this.generar(
lead,
historial,
mensajeActual,
clasificacion,
{ valido: false, valorNormalizado: null },
0,
false,
null,
true,
);
const respuesta = await this.aplicarReglas(
borrador,
lead,
'nuevo',
clasificacion,
);
return {
respuesta: this.contieneFraseProhibida(respuesta)
? this.mensajeFallback('nuevo', lead)
: respuesta,
nuevoEstado: 'apertura',
};
}
// También imprimimos el contenido del mensaje para ver lo que generó la IA const clasificacion = await this.clasificar(mensajeActual, estadoFlujo);
console.log('\n======= CONTENIDO DEL MENSAJE GENERADO ======='); const validacion = this.validar(clasificacion, estadoFlujo);
console.log(contenidoCompleto);
console.log('================================================\n');
const regexDatos = /<DATOS_EXTRAIDOS>([\s\S]*?)<\/DATOS_EXTRAIDOS>/; let reintentos = this.obtenerReintentos(lead.id, estadoFlujo);
const match = contenidoCompleto.match(regexDatos); let avanzarEstado = false;
let siguienteEstado: string | null = null;
let entidad: Partial<Lead> = {};
let viable: boolean | undefined;
let respuesta = contenidoCompleto.replace(regexDatos, '').trim(); const puedeAvanzar =
let entidad: Partial<Lead> = {}; validacion.valido &&
let viableFlag: boolean | undefined = undefined; !clasificacion.es_desvio &&
clasificacion.intencion === 'respuesta';
if (match) { if (puedeAvanzar) {
try { avanzarEstado = true;
const datos = JSON.parse(match[1].trim()); this.resetearReintentos(lead.id, estadoFlujo);
Object.entries(datos).forEach(([k, v]) => {
if (v !== null && k !== 'viable') { if (validacion.valorNormalizado) {
(entidad as Record<string, unknown>)[k] = v; const campo = this.leadsService.getCampoParaEstado(estadoFlujo);
} if (campo) {
}); entidad = { [campo]: validacion.valorNormalizado };
if (datos.viable !== null && datos.viable !== undefined) { } else if (
viableFlag = datos.viable; estadoFlujo === 'apertura' &&
} clasificacion.valor_extraido?.trim()
} catch (e) { ) {
this.logger.warn('No se pudo parsear DATOS_EXTRAIDOS: ' + e.message); entidad = { nombre: clasificacion.valor_extraido.trim() };
} }
} }
return { respuesta, entidad, viable: viableFlag }; if (estadoFlujo === 'presupuesto') {
} catch (error) { viable = validacion.viable;
this.logger.error( siguienteEstado = this.leadsService.getSiguienteEstado(
`Error llamando a Claude via OpenRouter: ${error.message}`, estadoFlujo,
error.response?.data, viable,
); );
throw error; } else {
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo);
}
} else if (
!validacion.valido &&
clasificacion.responde_pregunta &&
!clasificacion.es_desvio
) {
reintentos = this.incrementarReintentos(lead.id, estadoFlujo);
if (reintentos > 2) {
reintentos = 2;
}
} }
const borrador = await this.generar(
lead,
historial,
mensajeActual,
clasificacion,
validacion,
reintentos,
avanzarEstado,
siguienteEstado,
);
let respuesta = await this.aplicarReglas(
borrador,
lead,
estadoFlujo,
clasificacion,
);
if (this.contieneFraseProhibida(respuesta)) {
this.logger.warn(
`Respuesta final viola reglas de identidad, usando fallback para estado=${estadoFlujo}`,
);
respuesta = this.mensajeFallback(estadoFlujo, lead);
}
return {
respuesta,
entidad: Object.keys(entidad).length > 0 ? entidad : undefined,
viable,
nuevoEstado: avanzarEstado ? siguienteEstado ?? undefined : undefined,
};
} }
} }

View File

@@ -9,6 +9,14 @@ import {
export type EstadoLead = export type EstadoLead =
| 'nuevo' | 'nuevo'
| 'en_proceso' | 'en_proceso'
| 'apertura'
| 'espacio'
| 'tamano'
| 'estilo'
| 'urgencia'
| 'presupuesto'
| 'fin_viable'
| 'fin_no_viable'
| 'recopilando_datos' | 'recopilando_datos'
| 'completado' | 'completado'
| 'no_viable' | 'no_viable'

View File

@@ -3,6 +3,31 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan } from 'typeorm'; import { Repository, LessThan } from 'typeorm';
import { Lead, EstadoLead } from './lead.entity'; import { Lead, EstadoLead } from './lead.entity';
const SECUENCIA_ESTADOS = [
'nuevo',
'apertura',
'espacio',
'tamano',
'estilo',
'urgencia',
'presupuesto',
] as const;
const VALORES_POR_ESTADO: Record<string, string[]> = {
espacio: ['cocina', 'bano', 'salon', 'integral', 'otro'],
tamano: ['menos10', '10a20', '20a40', 'mas40'],
estilo: ['funcional', 'cuidado', 'exclusivo'],
urgencia: ['urgente', 'medio_plazo', 'frio'],
};
const CAMPO_POR_ESTADO: Record<string, keyof Lead> = {
espacio: 'espacio',
tamano: 'rango_m2',
estilo: 'estilo',
urgencia: 'urgencia',
presupuesto: 'presupuesto_declarado',
};
@Injectable() @Injectable()
export class LeadsService { export class LeadsService {
private readonly logger = new Logger(LeadsService.name); private readonly logger = new Logger(LeadsService.name);
@@ -12,6 +37,58 @@ export class LeadsService {
private readonly leadRepo: Repository<Lead>, private readonly leadRepo: Repository<Lead>,
) {} ) {}
/**
* Normaliza estados legacy del scheduler/DB al flujo de cualificacion.
*/
normalizarEstadoFlujo(estado: string): string {
if (estado === 'en_proceso' || estado === 'recopilando_datos') {
return 'apertura';
}
return estado;
}
getSiguienteEstado(estadoActual: string, viable?: boolean): string {
const estado = this.normalizarEstadoFlujo(estadoActual);
if (estado === 'presupuesto') {
return viable === false ? 'fin_no_viable' : 'fin_viable';
}
const idx = SECUENCIA_ESTADOS.indexOf(
estado as (typeof SECUENCIA_ESTADOS)[number],
);
if (idx === -1 || idx >= SECUENCIA_ESTADOS.length - 1) {
return estado;
}
return SECUENCIA_ESTADOS[idx + 1];
}
getValoresPermitidos(estado: string): string[] {
const estadoNorm = this.normalizarEstadoFlujo(estado);
return VALORES_POR_ESTADO[estadoNorm] ?? [];
}
getCampoParaEstado(estado: string): keyof Lead | null {
const estadoNorm = this.normalizarEstadoFlujo(estado);
return CAMPO_POR_ESTADO[estadoNorm] ?? null;
}
esPresupuestoValido(valor: string): boolean {
const normalizado = valor.trim().toLowerCase();
if (!normalizado) return false;
return /\d/.test(normalizado);
}
evaluarViabilidad(presupuesto: string): boolean {
const numeros = presupuesto.match(/\d[\d.]*/g);
if (!numeros?.length) return true;
const valor = parseInt(numeros[0].replace(/\./g, ''), 10);
if (Number.isNaN(valor)) return true;
return valor >= 5000;
}
/** /**
* Busca un lead por número de teléfono. * Busca un lead por número de teléfono.
* Si no existe, lo crea con estado 'nuevo'. * Si no existe, lo crea con estado 'nuevo'.
@@ -38,9 +115,12 @@ export class LeadsService {
return this.leadRepo.find({ where: { estado_actual: estado } }); return this.leadRepo.find({ where: { estado_actual: estado } });
} }
async updateEstado(lead: Lead, estado: EstadoLead): Promise<Lead> { async updateEstado(lead: Lead, estado: EstadoLead | string): Promise<Lead> {
lead.estado_actual = estado; await this.leadRepo.update(lead.id, {
return this.leadRepo.save(lead); estado_actual: estado as EstadoLead,
});
this.logger.log(`Lead id=${lead.id} estado_actual=${estado}`);
return this.leadRepo.findOne({ where: { id: lead.id } });
} }
/** /**
@@ -48,14 +128,62 @@ export class LeadsService {
* Solo actualiza los campos que se pasan en el partial. * Solo actualiza los campos que se pasan en el partial.
*/ */
async updateDatos(leadId: number, datos: Partial<Lead>): Promise<Lead> { async updateDatos(leadId: number, datos: Partial<Lead>): Promise<Lead> {
const campos = Object.keys(datos).filter(
(k) => datos[k as keyof Lead] !== undefined,
);
if (campos.length === 0) {
return this.leadRepo.findOne({ where: { id: leadId } });
}
await this.leadRepo.update(leadId, datos); await this.leadRepo.update(leadId, datos);
this.logger.log(
`Lead id=${leadId} datos guardados: ${JSON.stringify(datos)}`,
);
return this.leadRepo.findOne({ where: { id: leadId } }); return this.leadRepo.findOne({ where: { id: leadId } });
} }
async marcarViable(lead: Lead, viable: boolean): Promise<Lead> { async marcarViable(lead: Lead, viable: boolean): Promise<Lead> {
lead.viable = viable; const estado = viable ? 'completado' : 'no_viable';
lead.estado_actual = viable ? 'completado' : 'no_viable'; await this.leadRepo.update(lead.id, { viable, estado_actual: estado });
return this.leadRepo.save(lead); this.logger.log(`Lead id=${lead.id} viable=${viable}, estado=${estado}`);
return this.leadRepo.findOne({ where: { id: lead.id } });
}
/**
* Persiste datos del lead y cambio de estado en una sola operacion.
*/
async persistirTurno(
leadId: number,
datos: Partial<Lead>,
options?: { nuevoEstado?: string; viable?: boolean },
): Promise<Lead> {
const patch: Partial<Lead> = { ...datos };
if (options?.nuevoEstado === 'fin_viable') {
patch.viable = true;
patch.estado_actual = 'completado';
} else if (options?.nuevoEstado === 'fin_no_viable') {
patch.viable = false;
patch.estado_actual = 'no_viable';
} else if (options?.nuevoEstado) {
patch.estado_actual = options.nuevoEstado as EstadoLead;
} else if (options?.viable !== undefined && options?.viable !== null) {
patch.viable = options.viable;
patch.estado_actual = options.viable ? 'completado' : 'no_viable';
}
const campos = Object.keys(patch).filter(
(k) => patch[k as keyof Lead] !== undefined,
);
if (campos.length === 0) {
return this.leadRepo.findOne({ where: { id: leadId } });
}
await this.leadRepo.update(leadId, patch);
this.logger.log(
`Lead id=${leadId} persistido: ${JSON.stringify(patch)}`,
);
return this.leadRepo.findOne({ where: { id: leadId } });
} }
/** /**

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,
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.logger.debug(
this.OPENROUTER_URL, `[AUDIO 3/4] Enviando a OpenRouter — endpoint=${this.OPENROUTER_URL}, content_type=input_audio, format=${format}`,
{
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 },
); );
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

@@ -3,38 +3,49 @@ import {
Logger, Logger,
OnModuleInit, OnModuleInit,
OnModuleDestroy, OnModuleDestroy,
} from '@nestjs/common'; } from "@nestjs/common";
import makeWASocket, { import makeWASocket, {
DisconnectReason, DisconnectReason,
useMultiFileAuthState, useMultiFileAuthState,
fetchLatestBaileysVersion, fetchLatestBaileysVersion,
WASocket, WASocket,
downloadMediaMessage, downloadMediaMessage,
proto, 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";
const pino = require('pino'); const pino = require("pino");
const QRCode = require('qrcode-terminal'); const QRCode = require("qrcode-terminal");
import { LeadsService } from '../leads/leads.service'; 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 { Lead } from '../leads/lead.entity'; import { WhatsappDebounceService } from "./whatsapp-debounce.service";
import { wrapSocket } from 'baileys-antiban'; import { wrapSocket } from "baileys-antiban";
const ESTADOS_TERMINALES = [
"completado",
"no_viable",
"perdido",
"fin_viable",
"fin_no_viable",
];
@Injectable() @Injectable()
export class WhatsappService implements OnModuleInit, OnModuleDestroy { 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() {
await this.conectar(); await this.conectar();
@@ -46,31 +57,48 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
} }
} }
private normalizarTelefono(jid: string): string {
return jid.split("@")[0].replace(/\D/g, "");
}
private calcularDelayEscritura(longitudTexto: number): number {
const min = 1500;
const max = 4000;
const factor = Math.min(longitudTexto / 120, 1);
return Math.round(min + (max - min) * factor);
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private async conectar() { private async conectar() {
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,
}); });
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; const { connection, lastDisconnect, qr } = update;
if (qr) { if (qr) {
QRCode.generate(qr, { small: true }); 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 = const shouldReconnect =
(lastDisconnect?.error as Boom)?.output?.statusCode !== (lastDisconnect?.error as Boom)?.output?.statusCode !==
DisconnectReason.loggedOut; DisconnectReason.loggedOut;
@@ -83,73 +111,159 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
setTimeout(() => this.conectar(), 5000); setTimeout(() => this.conectar(), 5000);
} else { } else {
this.logger.error( this.logger.error(
'Sesion cerrada (logged out). Elimina auth_info_baileys y reinicia.', "Sesion cerrada (logged out). Elimina auth_info_baileys y reinicia.",
); );
} }
} else if (connection === 'open') { } else if (connection === "open") {
this.logger.log( this.logger.log(
'✅ WhatsApp conectado. Luisa esta lista para recibir mensajes.', "✅ WhatsApp conectado. Luisa esta lista para recibir mensajes.",
); );
} }
}); });
this.sock.ev.on('messages.upsert', async ({ messages, type }) => { this.sock.ev.on("messages.upsert", async ({ messages, type }) => {
if (type !== 'notify') return; if (type !== "notify") return;
for (const msg of messages) { for (const msg of messages) {
if (msg.key.fromMe) continue; if (msg.key.fromMe) continue;
if (!msg.key.remoteJid) continue; if (!msg.key.remoteJid) continue;
if (msg.key.remoteJid.includes('@g.us')) continue; if (msg.key.remoteJid.includes("@g.us")) continue;
await this.procesarMensaje(msg);
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;
}
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!;
// Ignorar grupos if (jid.includes("@g.us")) return;
if (jid.includes('@g.us')) return;
// Normalizar JID para envio (manejar formato LID de Baileys v7) const telefono = jid.split("@")[0];
const jidNormalizado = jid.includes('@lid')
? `${jid.split('@')[0]}@s.whatsapp.net`
: jid;
const telefono = jidNormalizado.replace('@s.whatsapp.net', '');
try { try {
const lead = await this.leadsService.findOrCreate(telefono); let lead = await this.leadsService.findOrCreate(telefono);
if (['completado', 'no_viable', 'perdido'].includes(lead.estado_actual)) { if (ESTADOS_TERMINALES.includes(lead.estado_actual)) {
this.logger.log(`Lead id=${lead.id} en estado=${lead.estado_actual}. Ignorando.`); this.logger.log(
`Lead id=${lead.id} en estado=${lead.estado_actual}. Ignorando.`,
);
return; return;
} }
let textoNormalizado = ''; let textoNormalizado = "";
const msgContent = msg.message; const msgContent = normalizeMessageContent(msg.message);
if (!msgContent) return; if (!msgContent) return;
if (msgContent.conversation || msgContent.extendedTextMessage) { if (msgContent.conversation || msgContent.extendedTextMessage) {
textoNormalizado = textoNormalizado =
msgContent.conversation || msgContent.conversation || msgContent.extendedTextMessage?.text || "";
msgContent.extendedTextMessage?.text ||
'';
} else if (msgContent.audioMessage) { } else if (msgContent.audioMessage) {
this.logger.log(`Audio recibido de lead id=${lead.id}. Transcribiendo...`); const audioMeta = msgContent.audioMessage;
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}); const mimeType = audioMeta.mimetype || "audio/ogg; codecs=opus";
const mimeType = msgContent.audioMessage.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( 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(`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`); this.logger.log(
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}); `Imagen recibida de lead id=${lead.id}. Analizando con Vision...`,
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg'; );
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( textoNormalizado = await this.mediaService.inferirImagen(
buffer as Buffer, Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer),
mimeType, mimeType,
lead.estado_actual, lead.estado_actual,
); );
@@ -157,7 +271,9 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`; textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`;
} }
} else { } 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 id=${lead.id}. Ignorando.`,
);
return; return;
} }
@@ -165,34 +281,44 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
this.logger.log(`USUARIO [${telefono}]: ${textoNormalizado}`); this.logger.log(`USUARIO [${telefono}]: ${textoNormalizado}`);
await this.conversacionService.guardarMensaje(lead.id, 'user', textoNormalizado); await this.conversacionService.guardarMensaje(
lead.id,
const historial = await this.conversacionService.obtenerHistorialComoMessages(lead.id); "user",
const { respuesta, entidad, viable } = await this.claudeService.llamarClaude(
lead,
historial.slice(0, -1),
textoNormalizado, 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}`); this.logger.log(`LUISA [${telefono}]: ${respuesta}`);
if (entidad && Object.keys(entidad).length > 0) { if (
await this.leadsService.updateDatos(lead.id, entidad); (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 (viable !== undefined && viable !== null) { await this.conversacionService.guardarMensaje(
await this.leadsService.marcarViable(lead, viable); lead.id,
this.logger.log(`Lead id=${lead.id} marcado como viable=${viable}`); "assistant",
} else { respuesta,
if (lead.estado_actual === 'nuevo') { );
await this.leadsService.updateEstado(lead, 'en_proceso');
}
}
await this.conversacionService.guardarMensaje(lead.id, 'assistant', respuesta);
await this.enviarMensaje(jid, respuesta); await this.enviarMensaje(jid, respuesta);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Error procesando mensaje de ${telefono}: ${error.message}`, `Error procesando mensaje de ${telefono}: ${error.message}`,
@@ -200,12 +326,19 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
); );
} }
} }
async enviarMensaje(jid: string, texto: string): Promise<void> { async enviarMensaje(jid: string, texto: string): Promise<void> {
if (!this.sock) { if (!this.sock) return;
this.logger.error('Socket de WhatsApp no disponible');
return;
}
try { try {
const jidPresencia = jid.includes("@lid")
? `${jid.split("@")[0]}@s.whatsapp.net`
: jid;
await this.sock.sendPresenceUpdate("composing", jidPresencia);
await this.delay(this.calcularDelayEscritura(texto.length));
await this.sock.sendPresenceUpdate("paused", jidPresencia);
const safeSock = wrapSocket(this.sock); const safeSock = wrapSocket(this.sock);
await safeSock.sendMessage(jid, { text: texto }); await safeSock.sendMessage(jid, { text: texto });
this.logger.log(`Mensaje enviado a ${jid}`); this.logger.log(`Mensaje enviado a ${jid}`);
@@ -214,7 +347,10 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
} }
} }
async enviarApertura(telefono: string, mensajeApertura: string): Promise<void> { async enviarApertura(
telefono: string,
mensajeApertura: string,
): Promise<void> {
const jid = `${telefono}@s.whatsapp.net`; const jid = `${telefono}@s.whatsapp.net`;
await this.enviarMensaje(jid, mensajeApertura); await this.enviarMensaje(jid, mensajeApertura);
} }
@@ -222,4 +358,4 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
isConectado(): boolean { isConectado(): boolean {
return this.sock !== null; return this.sock !== null;
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB