Configuracion de agente de whastapp paratrabajar con la estructura propuesta

This commit is contained in:
unknown
2026-06-07 17:51:53 -04:00
parent d3189d7277
commit fec365bb57
28 changed files with 5316 additions and 1748 deletions

View File

@@ -0,0 +1,137 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
export interface LeadState {
id: string;
nombre: string;
telefono: string;
botStep: string;
estadoWa: string;
espacio: string;
rangoM2: string;
estilo: string;
presupuestoDeclarado: string;
viable: boolean | null;
}
@Injectable()
export class ApiClient {
private readonly logger = new Logger(ApiClient.name);
private readonly baseUrl: string;
private readonly apiKey: string;
constructor() {
this.baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
this.apiKey = process.env.FUNNEL_API_KEY || '';
}
private get headers() {
return {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
};
}
async getLead(leadId: string): Promise<LeadState | null> {
try {
const { data } = await axios.get(`${this.baseUrl}/api/leads/${leadId}`, {
headers: this.headers,
});
return data;
} catch (err: any) {
if (err.response?.status === 404) return null;
this.logger.error(`Error fetching lead ${leadId}: ${err.message}`);
return null;
}
}
async guardarConversacion(
leadId: string,
rol: 'user' | 'assistant' | 'system',
mensaje: string,
options?: { estadoWa?: string; botStep?: string; mediaType?: string; mediaUrl?: string; transcripcionAudio?: string },
): Promise<boolean> {
return this.post(`/api/leads/${leadId}/conversacion`, {
rol,
mensaje,
...options,
});
}
async actualizarPerfil(
leadId: string,
datos: Record<string, unknown>,
): Promise<boolean> {
return this.post(`/api/leads/${leadId}/perfil`, datos);
}
async obtenerHistorial(leadId: string): Promise<Array<{ role: string; content: string }>> {
try {
const { data } = await axios.get(
`${this.baseUrl}/api/leads/${leadId}/conversacion`,
{ headers: this.headers },
);
if (Array.isArray(data)) {
return data.map((m: any) => ({ role: m.rol || m.role, content: m.mensaje || m.content }));
}
return [];
} catch (err: any) {
if (err.response?.status === 404) return [];
this.logger.error(`Error fetching historial for ${leadId}: ${err.message}`);
return [];
}
}
async calificarLead(
leadId: string,
score: number,
nivel: 'A' | 'B' | 'C' | 'D',
criterios?: Record<string, unknown>,
notasAgente?: string,
): Promise<boolean> {
return this.post(`/api/leads/${leadId}/calificacion`, {
score,
nivel,
criterios,
notasAgente,
});
}
async registrarIntento(
leadId: string,
canal: string,
numeroIntento: number,
resultado?: string,
completado?: boolean,
): Promise<boolean> {
return this.post(`/api/leads/${leadId}/intento`, {
canal,
numeroIntento,
resultado,
completado,
});
}
async enviarIngesta(
leadId: string,
items: Array<Record<string, unknown>>,
flags?: { perfilCompleto?: boolean; finalizar?: boolean },
): Promise<boolean> {
return this.post(`/api/leads/${leadId}/ingesta`, {
items,
...flags,
});
}
private async post(path: string, body: unknown): Promise<boolean> {
try {
const { status } = await axios.post(`${this.baseUrl}${path}`, body, {
headers: this.headers,
});
return status === 200;
} catch (err: any) {
this.logger.error(`POST ${path} error: ${err.response?.status} ${err.message}`);
return false;
}
}
}

View File

@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { ApiClient } from './api-client.service';
@Global()
@Module({
providers: [ApiClient],
exports: [ApiClient],
})
export class ApiModule {}

View File

@@ -1,34 +1,22 @@
import 'dotenv/config';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { ApiModule } from './api/api.module';
import { LeadsModule } from './leads/leads.module';
import { ConversacionModule } from './conversacion/conversacion.module';
import { WhatsappModule } from './whatsapp/whatsapp.module';
import { ClaudeModule } from './claude/claude.module';
import { MediaModule } from './media/media.module';
import { SchedulerModule } from './scheduler/scheduler.module';
import { Lead } from './leads/lead.entity';
import { Conversacion } from './conversacion/conversacion.entity';
import { WebhookModule } from './webhook/webhook.module';
@Module({
imports: [
ScheduleModule.forRoot(),
TypeOrmModule.forRoot({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: [Lead, Conversacion],
synchronize: true, // En produccion usar migrations en lugar de synchronize
ssl: process.env.DATABASE_URL?.includes('sslmode=require')
? { rejectUnauthorized: false }
: false,
}),
ApiModule,
LeadsModule,
ConversacionModule,
WhatsappModule,
ClaudeModule,
MediaModule,
SchedulerModule,
WebhookModule,
],
})
export class AppModule { }
export class AppModule {}

View File

@@ -2,7 +2,6 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { Lead } from '../leads/lead.entity';
import { LeadsService } from '../leads/leads.service';
const DEFAULT_SYSTEM_PROMPT =
@@ -34,9 +33,23 @@ export interface ValidacionResultado {
viable?: boolean;
}
export interface LeadBasico {
id: string;
telefono: string;
nombre: string;
estado_actual: string;
espacio: string | null;
rango_m2: string | null;
estilo: string | null;
urgencia: string | null;
presupuesto_declarado: string | null;
viable: boolean | null;
email: string | null;
}
export interface ClaudeResponse {
respuesta: string;
entidad?: Partial<Lead>;
entidad?: Partial<LeadBasico>;
viable?: boolean;
nuevoEstado?: string;
}
@@ -47,73 +60,39 @@ export class ClaudeService implements OnModuleInit {
private readonly promptsDir = path.join(process.cwd(), 'prompts');
private systemPromptCache = '';
private reglasPromptCache = '';
private readonly reintentosPorLead = new Map<
string,
{ estado: string; count: number }
>();
private readonly reintentosPorLead = new Map<string, { estado: string; count: number }>();
constructor(private readonly leadsService: LeadsService) {}
onModuleInit() {
this.systemPromptCache = this.cargarPrompts([
'luisa_core.md',
'luisa_flujo.md',
'luisa_casos.md',
]);
this.systemPromptCache = this.cargarPrompts(['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`,
);
this.logger.log(`Prompts cargados: system=${this.systemPromptCache.length} chars, reglas=${this.reglasPromptCache.length} chars`);
}
private cargarPrompts(archivos: string[]): string {
const partes: string[] = [];
for (const archivo of archivos) {
const rutaCompleta = path.join(this.promptsDir, archivo);
try {
if (!fs.existsSync(rutaCompleta)) {
this.logger.warn(`Prompt no encontrado: ${archivo}`);
continue;
}
if (!fs.existsSync(rutaCompleta)) { this.logger.warn(`Prompt no encontrado: ${archivo}`); continue; }
const contenido = fs.readFileSync(rutaCompleta, 'utf-8');
if (contenido.trim()) {
partes.push(`\n\n## ${archivo}\n${contenido}`);
}
} catch {
this.logger.warn(`No se pudo leer el prompt: ${archivo}`);
}
if (contenido.trim()) partes.push(`\n\n## ${archivo}\n${contenido}`);
} catch { this.logger.warn(`No se pudo leer el prompt: ${archivo}`); }
}
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;
return partes.join('\n').trim() || 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 = {
const envMap: Record<string, string | undefined> = {
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];
return envMap[clave] || (clave === 'generador' ? 'anthropic/claude-sonnet-4-5' : 'anthropic/claude-haiku-4-5');
}
private serializarLead(lead: Lead): string {
private serializarLead(lead: LeadBasico): string {
return [
`- ID: ${lead.id}`,
`- Telefono: ${lead.telefono}`,
@@ -129,10 +108,6 @@ export class ClaudeService implements OnModuleInit {
].join('\n');
}
/**
* OpenRouter requiere system dentro de messages[] para modelos OpenAI.
* El campo system en la raiz del payload no siempre se aplica.
*/
private async llamarOpenRouter(
model: string,
system: string,
@@ -140,95 +115,47 @@ export class ClaudeService implements OnModuleInit {
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' };
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 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;
});
return response.data.choices?.[0]?.message?.content || '';
}
private parsearJson<T>(texto: string): T | null {
const limpio = texto
.replace(/```json\s*/gi, '')
.replace(/```\s*/g, '')
.trim();
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;
}
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;
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';
? (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),
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> {
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.
@@ -247,132 +174,63 @@ Formato exacto:
Valores validos de intencion: respuesta, desvio, despedida, insulto, pregunta
Reglas para valor_extraido:
- espacio: cocina, bano, salon, integral, otro
- espacio: cocina, bano, salon, comedor, integral, otro
- tamano: menos10, 10a20, 20a40, mas40
- estilo: funcional, cuidado, exclusivo
- urgencia: urgente, medio_plazo, frio
- urgencia: alta, media, baja
- 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 },
];
- Extrae el valor semantico aunque venga en lenguaje coloquial`;
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) ?? {},
);
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 JSON invalido (intento): ${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',
};
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 {
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'
) {
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 === '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 };
return { valido: clasificacion.responde_pregunta && clasificacion.intencion === 'respuesta' && !clasificacion.es_desvio, 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 };
if (!valor || !this.leadsService.esPresupuestoValido(valor)) return { valido: false, valorNormalizado: null };
return { valido: true, valorNormalizado: valor, viable: this.leadsService.evaluarViabilidad(valor) };
}
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;
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, '');
return valor.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
}
private claveReintento(leadId: number, estado: string): string {
return `${leadId}:${estado}`;
}
private claveReintento(leadId: string, 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);
private obtenerReintentos(leadId: string, estado: string): number {
const entry = this.reintentosPorLead.get(this.claveReintento(leadId, estado));
return entry?.estado === estado ? entry.count : 0;
}
private incrementarReintentos(leadId: number, estado: string): number {
private incrementarReintentos(leadId: string, estado: string): number {
const clave = this.claveReintento(leadId, estado);
const actual = this.obtenerReintentos(leadId, estado);
const count = actual + 1;
@@ -380,15 +238,12 @@ Reglas para valor_extraido:
return count;
}
private resetearReintentos(leadId: number, estado: string): void {
private resetearReintentos(leadId: string, 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: LeadBasico,
historial: Array<{ role: string; content: string }>,
mensajeActual: string,
clasificacion: ClasificacionResultado,
@@ -398,11 +253,7 @@ Reglas para valor_extraido:
siguienteEstado: string | null,
forzarApertura = false,
): Promise<string> {
const systemPrompt = this.leerPromptsSistema();
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(
lead.estado_actual,
);
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
const contextoGeneracion = `
## Contexto del lead
${this.serializarLead(lead)}
@@ -419,11 +270,10 @@ ${this.serializarLead(lead)}
## 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).
Habla espanol de Espana; suena natural y cercana.
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.
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.
@@ -432,32 +282,16 @@ Si es_desvio es true o intencion es pregunta, responde brevemente como Luisa y r
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.`;
const messages = [
...historial,
{ role: 'user', content: mensajeActual },
];
const contenido = await this.llamarOpenRouter(
this.getModelo('generador'),
`${systemPrompt}\n${contextoGeneracion}`,
messages,
const contenido = await this.llamarOpenRouter(this.getModelo('generador'),
`${this.systemPromptCache || DEFAULT_SYSTEM_PROMPT}\n${contextoGeneracion}`,
[...historial, { role: 'user', content: mensajeActual }],
{ 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();
private async aplicarReglas(borrador: string, lead: LeadBasico, estadoFlujo: string, clasificacion: ClasificacionResultado): Promise<string> {
const reglas = this.reglasPromptCache || DEFAULT_SYSTEM_PROMPT;
const system = `${reglas}
## Tu tarea
@@ -471,19 +305,16 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
## 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
- Espanol de Espana, natural; puede usar coloquialismos suaves (vale, mira, oye) si encaja
- 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,
const contenido = await this.llamarOpenRouter(this.getModelo('reglas'), system,
[{ role: 'user', content: `Borrador a corregir:\n${borrador}` }],
{ temperature: 0.3 },
);
return contenido.trim() || borrador;
}
@@ -491,7 +322,7 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
return FRASES_IA_PROHIBIDAS.some((regex) => regex.test(texto));
}
private mensajeFallback(estadoFlujo: string, lead: Lead): string {
private mensajeFallback(estadoFlujo: string, lead: LeadBasico): 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?`,
@@ -502,91 +333,23 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
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?',
};
return (
fallbacks[estadoFlujo] ??
'Soy Luisa de Reformix; sigamos con tu presupuesto de reforma.'
);
return fallbacks[estadoFlujo] ?? 'Soy Luisa de Reformix; sigamos con tu presupuesto de reforma.';
}
/**
* Orquesta las 4 capas: clasificar, validar, generar y aplicar reglas.
*/
async llamarClaude(
lead: Lead,
lead: LeadBasico,
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,
{
responde_pregunta: true,
valor_extraido: null,
es_desvio: false,
intencion: 'respuesta',
},
{ 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,
};
}
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(
lead.estado_actual,
);
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
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,
);
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,
respuesta: this.contieneFraseProhibida(respuesta) ? this.mensajeFallback('nuevo', lead) : respuesta,
nuevoEstado: 'apertura',
};
}
@@ -597,72 +360,38 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
let reintentos = this.obtenerReintentos(lead.id, estadoFlujo);
let avanzarEstado = false;
let siguienteEstado: string | null = null;
let entidad: Partial<Lead> = {};
let entidad: Partial<LeadBasico> = {};
let viable: boolean | undefined;
const puedeAvanzar =
validacion.valido &&
!clasificacion.es_desvio &&
clasificacion.intencion === 'respuesta';
const puedeAvanzar = validacion.valido && !clasificacion.es_desvio && clasificacion.intencion === 'respuesta';
if (puedeAvanzar) {
avanzarEstado = true;
this.resetearReintentos(lead.id, estadoFlujo);
if (validacion.valorNormalizado) {
const campo = this.leadsService.getCampoParaEstado(estadoFlujo);
if (campo) {
entidad = { [campo]: validacion.valorNormalizado };
} else if (
estadoFlujo === 'apertura' &&
clasificacion.valor_extraido?.trim()
) {
entidad = { nombre: clasificacion.valor_extraido.trim() };
(entidad as any)[campo] = validacion.valorNormalizado;
} else if (estadoFlujo === 'apertura' && clasificacion.valor_extraido?.trim()) {
entidad.nombre = clasificacion.valor_extraido.trim();
}
}
if (estadoFlujo === 'presupuesto') {
viable = validacion.viable;
siguienteEstado = this.leadsService.getSiguienteEstado(
estadoFlujo,
viable,
);
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo, viable);
} else {
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo);
}
} else if (
!validacion.valido &&
clasificacion.responde_pregunta &&
!clasificacion.es_desvio
) {
} else if (!validacion.valido && clasificacion.responde_pregunta && !clasificacion.es_desvio) {
reintentos = this.incrementarReintentos(lead.id, estadoFlujo);
if (reintentos > 2) {
reintentos = 2;
}
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,
);
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}`,
);
this.logger.warn(`Respuesta final viola reglas, usando fallback para estado=${estadoFlujo}`);
respuesta = this.mensajeFallback(estadoFlujo, lead);
}

View File

@@ -1,33 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Lead } from '../leads/lead.entity';
export type RolMensaje = 'user' | 'assistant' | 'system';
@Entity('conversacion')
export class Conversacion {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'integer' })
lead_id: number;
@ManyToOne(() => Lead, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'lead_id' })
lead: Lead;
@Column({ type: 'text' })
rol: RolMensaje;
@Column({ type: 'text' })
mensaje: string;
@CreateDateColumn()
created_at: Date;
}

View File

@@ -1,10 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Conversacion } from './conversacion.entity';
import { ConversacionService } from './conversacion.service';
@Module({
imports: [TypeOrmModule.forFeature([Conversacion])],
providers: [ConversacionService],
exports: [ConversacionService],
})

View File

@@ -1,41 +1,26 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Conversacion, RolMensaje } from './conversacion.entity';
import { Injectable, Logger } from '@nestjs/common';
import { ApiClient } from '../api/api-client.service';
@Injectable()
export class ConversacionService {
constructor(
@InjectRepository(Conversacion)
private readonly convRepo: Repository<Conversacion>,
) {}
private readonly logger = new Logger(ConversacionService.name);
constructor(private readonly api: ApiClient) {}
async guardarMensaje(
leadId: number,
rol: RolMensaje,
leadId: string,
rol: 'user' | 'assistant' | 'system',
mensaje: string,
): Promise<Conversacion> {
const entry = this.convRepo.create({ lead_id: leadId, rol, mensaje });
return this.convRepo.save(entry);
options?: { estadoWa?: string; botStep?: string },
): Promise<boolean> {
const ok = await this.api.guardarConversacion(leadId, rol, mensaje, options);
if (!ok) {
this.logger.warn(`No se pudo guardar mensaje ${rol} para lead ${leadId}`);
}
return ok;
}
async obtenerHistorial(leadId: number): Promise<Conversacion[]> {
return this.convRepo.find({
where: { lead_id: leadId },
order: { created_at: 'ASC' },
});
}
/**
* Devuelve el historial en formato OpenAI/Claude messages array.
*/
async obtenerHistorialComoMessages(
leadId: number,
): Promise<Array<{ role: string; content: string }>> {
const historial = await this.obtenerHistorial(leadId);
return historial.map((h) => ({
role: h.rol,
content: h.mensaje,
}));
async obtenerHistorialComoMessages(leadId: string): Promise<Array<{ role: string; content: string }>> {
return this.api.obtenerHistorial(leadId);
}
}

View File

@@ -1,68 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
export type EstadoLead =
| 'nuevo'
| 'en_proceso'
| 'apertura'
| 'espacio'
| 'tamano'
| 'estilo'
| 'urgencia'
| 'presupuesto'
| 'fin_viable'
| 'fin_no_viable'
| 'recopilando_datos'
| 'completado'
| 'no_viable'
| 'perdido';
@Entity('leads')
export class Lead {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'text', nullable: true })
nombre: string;
@Column({ type: 'text', nullable: true })
telefono: string;
@Column({ type: 'text', nullable: true })
email: string;
@Column({ type: 'text', nullable: true })
espacio: string;
@Column({ type: 'text', nullable: true })
rango_m2: string;
@Column({ type: 'text', nullable: true })
estilo: string;
@Column({ type: 'text', nullable: true })
urgencia: string;
@Column({ type: 'text', nullable: true })
presupuesto_declarado: string;
@Column({ type: 'boolean', nullable: true })
viable: boolean;
@Column({ type: 'text', default: 'nuevo' })
estado_actual: EstadoLead;
@Column({ type: 'text', nullable: true })
url_presupuesto: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -1,10 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Lead } from './lead.entity';
import { LeadsService } from './leads.service';
@Module({
imports: [TypeOrmModule.forFeature([Lead])],
providers: [LeadsService],
exports: [LeadsService],
})

View File

@@ -1,7 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan } from 'typeorm';
import { Lead, EstadoLead } from './lead.entity';
import { ApiClient } from '../api/api-client.service';
const SECUENCIA_ESTADOS = [
'nuevo',
@@ -14,200 +12,83 @@ const SECUENCIA_ESTADOS = [
] as const;
const VALORES_POR_ESTADO: Record<string, string[]> = {
espacio: ['cocina', 'bano', 'salon', 'integral', 'otro'],
espacio: ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'],
tamano: ['menos10', '10a20', '20a40', 'mas40'],
estilo: ['funcional', 'cuidado', 'exclusivo'],
urgencia: ['urgente', 'medio_plazo', 'frio'],
urgencia: ['alta', 'media', 'baja'],
};
const CAMPO_POR_ESTADO: Record<string, keyof Lead> = {
const CAMPO_POR_ESTADO_NOMBRE: Record<string, string> = {
espacio: 'espacio',
tamano: 'rango_m2',
tamano: 'rangoM2',
estilo: 'estilo',
urgencia: 'urgencia',
presupuesto: 'presupuesto_declarado',
presupuesto: 'presupuestoDeclarado',
};
@Injectable()
export class LeadsService {
private readonly logger = new Logger(LeadsService.name);
constructor(
@InjectRepository(Lead)
private readonly leadRepo: Repository<Lead>,
) {}
constructor(private readonly api: ApiClient) {}
/**
* Normaliza estados legacy del scheduler/DB al flujo de cualificacion.
*/
normalizarEstadoFlujo(estado: string): string {
if (estado === 'en_proceso' || estado === 'recopilando_datos') {
return 'apertura';
}
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;
}
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] ?? [];
return VALORES_POR_ESTADO[this.normalizarEstadoFlujo(estado)] ?? [];
}
getCampoParaEstado(estado: string): keyof Lead | null {
const estadoNorm = this.normalizarEstadoFlujo(estado);
return CAMPO_POR_ESTADO[estadoNorm] ?? null;
getCampoParaEstado(estado: string): string | null {
return CAMPO_POR_ESTADO_NOMBRE[this.normalizarEstadoFlujo(estado)] ?? null;
}
esPresupuestoValido(valor: string): boolean {
const normalizado = valor.trim().toLowerCase();
if (!normalizado) return false;
return /\d/.test(normalizado);
return /\d/.test(valor.trim().toLowerCase());
}
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.
* Si no existe, lo crea con estado 'nuevo'.
*/
async findOrCreate(telefono: string): Promise<Lead> {
let lead = await this.leadRepo.findOne({ where: { telefono } });
if (!lead) {
lead = this.leadRepo.create({ telefono, estado_actual: 'nuevo' });
lead = await this.leadRepo.save(lead);
this.logger.log(`Lead nuevo creado: telefono=${telefono}, id=${lead.id}`);
}
return lead;
}
async findByTelefono(telefono: string): Promise<Lead | null> {
return this.leadRepo.findOne({ where: { telefono } });
}
async findById(id: number): Promise<Lead | null> {
return this.leadRepo.findOne({ where: { id } });
}
async findByEstado(estado: EstadoLead): Promise<Lead[]> {
return this.leadRepo.find({ where: { estado_actual: estado } });
}
async updateEstado(lead: Lead, estado: EstadoLead | string): Promise<Lead> {
await this.leadRepo.update(lead.id, {
estado_actual: estado as EstadoLead,
});
this.logger.log(`Lead id=${lead.id} estado_actual=${estado}`);
return this.leadRepo.findOne({ where: { id: lead.id } });
}
/**
* Actualiza campos del lead según el estado actual del flujo.
* Solo actualiza los campos que se pasan en el partial.
*/
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);
this.logger.log(
`Lead id=${leadId} datos guardados: ${JSON.stringify(datos)}`,
);
return this.leadRepo.findOne({ where: { id: leadId } });
}
async marcarViable(lead: Lead, viable: boolean): Promise<Lead> {
const estado = viable ? 'completado' : 'no_viable';
await this.leadRepo.update(lead.id, { viable, estado_actual: estado });
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>,
leadId: string,
datos: Record<string, unknown>,
options?: { nuevoEstado?: string; viable?: boolean },
): Promise<Lead> {
const patch: Partial<Lead> = { ...datos };
): Promise<boolean> {
const perfil: Record<string, unknown> = { ...datos };
if (options?.nuevoEstado === 'fin_viable') {
patch.viable = true;
patch.estado_actual = 'completado';
perfil.viable = true;
perfil.botStep = 'presupuesto';
} else if (options?.nuevoEstado === 'fin_no_viable') {
patch.viable = false;
patch.estado_actual = 'no_viable';
perfil.viable = false;
perfil.botStep = 'presupuesto';
} 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';
perfil.botStep = options.nuevoEstado;
} else if (options?.viable !== undefined) {
perfil.viable = options.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 } });
}
const campos = Object.keys(perfil).filter((k) => perfil[k] !== undefined);
if (campos.length === 0) return true;
await this.leadRepo.update(leadId, patch);
this.logger.log(
`Lead id=${leadId} persistido: ${JSON.stringify(patch)}`,
);
return this.leadRepo.findOne({ where: { id: leadId } });
}
/**
* Marca como perdido cualquier lead en_proceso sin actividad en más de 48h.
*/
async marcarLeadsPerdidos(): Promise<void> {
const hace48h = new Date(Date.now() - 48 * 60 * 60 * 1000);
const leadsSinActividad = await this.leadRepo.find({
where: {
estado_actual: 'en_proceso',
updated_at: LessThan(hace48h),
},
});
for (const lead of leadsSinActividad) {
lead.estado_actual = 'perdido';
await this.leadRepo.save(lead);
this.logger.warn(
`Lead id=${lead.id} marcado como perdido por inactividad > 48h`,
);
}
}
async save(lead: Lead): Promise<Lead> {
return this.leadRepo.save(lead);
const ok = await this.api.actualizarPerfil(leadId, perfil);
this.logger.log(`Lead ${leadId} persistido via API: ${JSON.stringify(perfil)}${ok ? 'ok' : 'fallo'}`);
return ok;
}
}

View File

@@ -1,268 +1,99 @@
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';
@Injectable()
export class MediaService {
private readonly logger = new Logger(MediaService.name);
private readonly OPENROUTER_URL =
"https://openrouter.ai/api/v1/chat/completions";
private readonly OPENROUTER_URL = '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"
);
return process.env.MODEL_TRANSCRIPCION || 'google/gemini-2.5-flash';
}
/**
* 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";
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();
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";
}
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; codecs=opus",
): Promise<string> {
const FALLBACK =
"No te he oido bien, me lo repites?";
async transcribirAudio(audioBuffer: Buffer, mimeType = 'audio/ogg; codecs=opus'): Promise<string> {
const FALLBACK = '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 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) return FALLBACK;
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.";
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 payload = {
const response = await axios.post(this.OPENROUTER_URL, {
model,
messages: [
{ role: "system", content: systemPrompt },
{
role: "user",
content: [
{ type: "text", text: userPrompt },
{
type: "input_audio",
input_audio: {
data: base64Audio,
format,
},
},
],
},
{ role: 'system', content: systemPrompt },
{ role: 'user', content: [{ type: 'text', text: userPrompt }, { type: 'input_audio', input_audio: { data: base64Audio, format } }] },
],
max_tokens: 512,
temperature: 0,
};
this.logger.debug(
`[AUDIO 3/4] Enviando a OpenRouter — endpoint=${this.OPENROUTER_URL}, content_type=input_audio, format=${format}`,
);
const response = await axios.post(this.OPENROUTER_URL, payload, {
headers: this.headers,
});
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 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(
`[AUDIO 3/4] Error transcribiendo audio: ${error.message}`,
error.response?.data,
);
max_tokens: 512, temperature: 0,
}, { headers: this.headers });
const raw: string = response.data.choices?.[0]?.message?.content?.trim() ?? '';
if (!raw) return FALLBACK;
return this.limpiarTranscripcion(raw) || FALLBACK;
} catch (error: any) {
this.logger.error(`Error transcribiendo audio: ${error.message}`);
return FALLBACK;
}
}
/**
* Infiere informacion de una imagen segun el estado actual del lead.
*/
async inferirImagen(
imagenBuffer: Buffer,
mimeType = "image/jpeg",
estadoActual: EstadoLead = "en_proceso",
): Promise<string> {
const FALLBACK =
"Recibi tu imagen pero no pude analizarla bien. Puedes describirme lo que muestra?";
async inferirImagen(imagenBuffer: Buffer, mimeType = 'image/jpeg', estadoActual = 'en_proceso'): Promise<string> {
const FALLBACK = 'Recibi tu imagen pero no pude analizarla bien. Puedes describirme lo que muestra?';
const promptPorEstado: Record<string, string> = {
nuevo:
"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 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 conservacion.",
completado:
"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.",
nuevo: '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 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 conservacion.',
completado: '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 promptDeVision =
promptPorEstado[estadoActual] ||
"Describe que ves en esta imagen en el contexto de una reforma de hogar.";
const promptDeVision = promptPorEstado[estadoActual] || 'Describe que ves en esta imagen en el contexto de una reforma de hogar.';
try {
const base64Imagen = imagenBuffer.toString("base64");
const response = await axios.post(
this.OPENROUTER_URL,
{
model:
process.env.MODEL_GENERADOR ||
process.env.MODEL ||
"anthropic/claude-sonnet-4-5",
messages: [
{
role: "user",
content: [
{ type: "text", text: promptDeVision },
{
type: "image_url",
image_url: {
url: `data:${mimeType};base64,${base64Imagen}`,
},
},
],
},
],
max_tokens: 512,
},
{ headers: this.headers },
);
const inferencia: string =
response.data.choices?.[0]?.message?.content?.trim();
if (!inferencia) {
this.logger.warn("Claude devolvio respuesta vacia para la imagen");
return FALLBACK;
}
this.logger.log(
`Imagen inferida correctamente (${inferencia.length} chars)`,
);
return inferencia;
} catch (error) {
this.logger.error(
`Error analizando imagen: ${error.message}`,
error.response?.data,
);
const base64Imagen = imagenBuffer.toString('base64');
const response = await axios.post(this.OPENROUTER_URL, {
model: process.env.MODEL_GENERADOR || process.env.MODEL || 'anthropic/claude-sonnet-4-5',
messages: [{ role: 'user', content: [{ type: 'text', text: promptDeVision }, { type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Imagen}` } }] }],
max_tokens: 512,
}, { headers: this.headers });
const inferencia: string = response.data.choices?.[0]?.message?.content?.trim();
return inferencia || FALLBACK;
} catch (error: any) {
this.logger.error(`Error analizando imagen: ${error.message}`);
return FALLBACK;
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { SchedulerService } from './scheduler.service';
import { LeadsModule } from '../leads/leads.module';
import { ConversacionModule } from '../conversacion/conversacion.module';
import { WhatsappModule } from '../whatsapp/whatsapp.module';
import { ClaudeModule } from '../claude/claude.module';
@Module({
imports: [LeadsModule, ConversacionModule, WhatsappModule, ClaudeModule],
providers: [SchedulerService],
})
export class SchedulerModule {}

View File

@@ -1,86 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { LeadsService } from '../leads/leads.service';
import { ConversacionService } from '../conversacion/conversacion.service';
import { WhatsappService } from '../whatsapp/whatsapp.service';
import { ClaudeService } from '../claude/claude.service';
@Injectable()
export class SchedulerService {
private readonly logger = new Logger(SchedulerService.name);
constructor(
private readonly leadsService: LeadsService,
private readonly conversacionService: ConversacionService,
private readonly whatsappService: WhatsappService,
private readonly claudeService: ClaudeService,
) {}
/**
* Cada 5 minutos:
* 1. Busca leads con estado_actual = 'nuevo'
* 2. Los marca como 'en_proceso'
* 3. Les envía el mensaje de APERTURA de Luisa
*
* También marca como perdidos los leads en_proceso sin actividad > 48h.
*/
@Cron(CronExpression.EVERY_5_MINUTES)
async procesarLeadsNuevos(): Promise<void> {
this.logger.log('[Scheduler] Buscando leads nuevos...');
// Primero limpiar leads inactivos
await this.leadsService.marcarLeadsPerdidos();
// Obtener leads nuevos
const leadsNuevos = await this.leadsService.findByEstado('nuevo');
if (leadsNuevos.length === 0) {
this.logger.log('[Scheduler] No hay leads nuevos.');
return;
}
this.logger.log(
`[Scheduler] Procesando ${leadsNuevos.length} lead(s) nuevo(s).`,
);
for (const lead of leadsNuevos) {
try {
// Marcar como en_proceso antes de hacer nada
await this.leadsService.updateEstado(lead, 'en_proceso');
this.logger.log(
`[Scheduler] Lead id=${lead.id} marcado como en_proceso.`,
);
// Generar mensaje de apertura con Claude usando contexto mínimo
const historialVacio: Array<{ role: string; content: string }> = [];
const mensajeDeApertura =
'APERTURA: Este es el primer mensaje. Preséntate y comienza el flujo de cualificación.';
const { respuesta } = await this.claudeService.llamarClaude(
lead,
historialVacio,
mensajeDeApertura,
);
// Guardar el mensaje de apertura en historial (como assistant)
await this.conversacionService.guardarMensaje(
lead.id,
'assistant',
respuesta,
);
// Enviar por WhatsApp
await this.whatsappService.enviarApertura(lead.telefono, respuesta);
this.logger.log(
`[Scheduler] Apertura enviada a lead id=${lead.id} (${lead.telefono}).`,
);
} catch (error) {
this.logger.error(
`[Scheduler] Error procesando lead id=${lead.id}: ${error.message}`,
error.stack,
);
}
}
}
}

View File

@@ -0,0 +1,85 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import * as http from 'http';
import { ApiClient } from '../api/api-client.service';
@Injectable()
export class WebhookListener implements OnApplicationBootstrap {
private readonly logger = new Logger(WebhookListener.name);
private server: http.Server | null = null;
constructor(private readonly api: ApiClient) {}
onApplicationBootstrap() {
const port = parseInt(process.env.WEBHOOK_PORT || '3001', 10);
this.server = http.createServer((req, res) => this.handleRequest(req, res));
this.server.listen(port, () => {
this.logger.log(`Webhook listener en puerto ${port}`);
this.logger.log(`WHATSAPP_START → POST /whatsapp-start`);
this.logger.log(`WHATSAPP_PDF → POST /whatsapp-pdf`);
});
}
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
if (req.method !== 'POST') {
res.writeHead(405).end('Method Not Allowed');
return;
}
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', async () => {
let payload: any;
try {
payload = JSON.parse(body);
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'JSON invalido' }));
return;
}
const url = req.url || '';
try {
if (url === '/whatsapp-start') {
await this.handleWhatsappStart(payload);
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
} else if (url === '/whatsapp-pdf') {
await this.handleWhatsappPdf(payload);
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
} else {
res.writeHead(404).end('Not Found');
}
} catch (err: any) {
this.logger.error(`Error handling ${url}: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, error: err.message }));
}
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private leadSessions = new Map<string, { leadId: string; telefono: string; nombre: string; jid: string | null }>();
private async handleWhatsappStart(payload: { leadId: string; telefono: string; nombre: string; empresa: string }) {
const { leadId, telefono, nombre } = payload;
this.logger.log(`[START] leadId=${leadId}, telefono=${telefono}, nombre=${nombre}`);
this.leadSessions.set(telefono, { leadId, telefono, nombre, jid: null });
this.logger.log(`Lead ${leadId} registrado en sesiones.`);
}
getLeadIdByTelefono(telefono: string): string | null {
return this.leadSessions.get(telefono)?.leadId ?? null;
}
registerJid(telefono: string, jid: string) {
const session = this.leadSessions.get(telefono);
if (session) {
session.jid = jid;
}
}
private async handleWhatsappPdf(payload: { leadId: string; telefono: string; pdfBase64: string; filename: string }) {
this.logger.log(`[PDF] leadId=${payload.leadId}, filename=${payload.filename}`);
const { pdfEmitter } = await import('../whatsapp/whatsapp.service');
const telefono = payload.telefono.startsWith('+') ? payload.telefono.slice(1) : payload.telefono;
pdfEmitter.emit('pdf', { ...payload, telefono });
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { WebhookListener } from './webhook-listener';
import { ApiModule } from '../api/api.module';
@Module({
imports: [ApiModule],
providers: [WebhookListener],
exports: [WebhookListener],
})
export class WebhookModule {}

View File

@@ -5,9 +5,10 @@ import { LeadsModule } from '../leads/leads.module';
import { ConversacionModule } from '../conversacion/conversacion.module';
import { ClaudeModule } from '../claude/claude.module';
import { MediaModule } from '../media/media.module';
import { WebhookModule } from '../webhook/webhook.module';
@Module({
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule],
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule, WebhookModule],
providers: [WhatsappService, WhatsappDebounceService],
exports: [WhatsappService],
})

View File

@@ -1,9 +1,10 @@
import { EventEmitter } from 'events';
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from "@nestjs/common";
} from '@nestjs/common';
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
@@ -11,33 +12,42 @@ import makeWASocket, {
WASocket,
downloadMediaMessage,
normalizeMessageContent,
} from "@whiskeysockets/baileys";
import { Boom } from "@hapi/boom";
import * as path from "path";
const pino = require("pino");
const QRCode = require("qrcode-terminal");
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";
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import * as path from 'path';
const pino = require('pino');
const QRCode = require('qrcode-terminal');
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 { WebhookListener } from '../webhook/webhook-listener';
import { ApiClient } from '../api/api-client.service';
import { wrapSocket } from 'baileys-antiban';
const ESTADOS_TERMINALES = [
"completado",
"no_viable",
"perdido",
"fin_viable",
"fin_no_viable",
];
export const pdfEmitter = new EventEmitter();
interface LeadContext {
leadId: string;
telefono: string;
nombre: string;
botStep: string;
viable: boolean | null;
}
@Injectable()
export class WhatsappService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(WhatsappService.name);
private sock: WASocket | null = null;
private authDir = path.join(process.cwd(), "auth_info_baileys");
private authDir = path.join(process.cwd(), 'auth_info_baileys');
private readonly ultimoMsgPorJid = new Map<string, any>();
private baileysLogger = pino({ level: "info" });
private baileysLogger = pino({ level: 'info' });
// leadId por JID
private readonly jidToLeadId = new Map<string, string>();
// contexto de lead por leadId
private readonly leadCache = new Map<string, LeadContext>();
constructor(
private readonly leadsService: LeadsService,
@@ -45,20 +55,51 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
private readonly claudeService: ClaudeService,
private readonly mediaService: MediaService,
private readonly debounceService: WhatsappDebounceService,
private readonly webhookListener: WebhookListener,
private readonly api: ApiClient,
) {}
async onModuleInit() {
await this.conectar();
this.escucharPdf();
}
async onModuleDestroy() {
if (this.sock) {
this.sock.end(undefined);
}
if (this.sock) this.sock.end(undefined);
}
private escucharPdf() {
pdfEmitter.on('pdf', async (payload: { leadId: string; telefono: string; pdfBase64: string; filename: string }) => {
this.logger.log(`[PDF] Recibido para leadId=${payload.leadId}`);
// Buscar JID por teléfono
let jid: string | null = null;
for (const [j, lid] of this.jidToLeadId) {
if (lid === payload.leadId) {
jid = j;
break;
}
}
if (!jid) {
jid = `${payload.telefono}@s.whatsapp.net`;
}
if (!this.sock) return;
try {
const safeSock = wrapSocket(this.sock);
await safeSock.sendMessage(jid, {
document: Buffer.from(payload.pdfBase64, 'base64'),
mimetype: 'application/pdf',
fileName: payload.filename,
caption: 'Aquí tienes tu presupuesto. Si tienes cualquier duda, estamos aquí.',
});
this.logger.log(`PDF enviado a ${jid}`);
} catch (err: any) {
this.logger.error(`Error enviando PDF a ${jid}: ${err.message}`);
}
});
}
private normalizarTelefono(jid: string): string {
return jid.split("@")[0].replace(/\D/g, "");
return jid.split('@')[0].replace(/\D/g, '');
}
private calcularDelayEscritura(longitudTexto: number): number {
@@ -76,7 +117,7 @@ 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.baileysLogger = pino({ level: 'info' }) as any;
this.sock = makeWASocket({
version,
@@ -88,56 +129,37 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
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;
if (qr) {
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 =
(lastDisconnect?.error as Boom)?.output?.statusCode !==
DisconnectReason.loggedOut;
this.logger.warn(
`Conexion cerrada. Reconectar: ${shouldReconnect}. Razon: ${lastDisconnect?.error?.message}`,
);
if (shouldReconnect) {
setTimeout(() => this.conectar(), 5000);
} else {
this.logger.error(
"Sesion cerrada (logged out). Elimina auth_info_baileys y reinicia.",
);
}
} else if (connection === "open") {
this.logger.log(
"✅ WhatsApp conectado. Luisa esta lista para recibir mensajes.",
);
(lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut;
this.logger.warn(`Conexion cerrada. Reconectar: ${shouldReconnect}.`);
if (shouldReconnect) setTimeout(() => this.conectar(), 5000);
else this.logger.error('Sesion cerrada (logged out).');
} else if (connection === 'open') {
this.logger.log('✅ WhatsApp conectado. Luisa esta lista.');
}
});
this.sock.ev.on("messages.upsert", async ({ messages, type }) => {
if (type !== "notify") return;
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
if (type !== 'notify') return;
for (const msg of messages) {
if (msg.key.fromMe) continue;
if (!msg.key.remoteJid) continue;
if (msg.key.remoteJid.includes("@g.us")) continue;
if (msg.key.remoteJid.includes('@g.us')) continue;
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;
}
const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, '');
if (allowedNumber && telefonoNormalizado !== allowedNumber) continue;
await this.encolarMensaje(msg);
}
@@ -147,21 +169,15 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
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 || "";
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 },
};
return { ...msg, message: { conversation: texto } };
}
private async encolarMensaje(msg: any): Promise<void> {
@@ -174,7 +190,6 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
}
this.ultimoMsgPorJid.set(jid, msg);
await this.debounceService.add(jid, textoPlano, async (combinedMessage) => {
const baseMsg = this.ultimoMsgPorJid.get(jid) ?? msg;
this.ultimoMsgPorJid.delete(jid);
@@ -182,179 +197,184 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
});
}
private async getOrCreateContext(telefono: string, jid: string): Promise<LeadContext | null> {
const leadId = this.webhookListener.getLeadIdByTelefono(telefono);
if (!leadId) {
this.logger.log(`Mensaje ignorado de ${telefono}: lead no registrado. Debe iniciarse desde la web.`);
return null;
}
this.webhookListener.registerJid(telefono, jid);
this.jidToLeadId.set(jid, leadId);
let ctx = this.leadCache.get(leadId);
if (!ctx) {
const lead = await this.api.getLead(leadId);
ctx = {
leadId,
telefono,
nombre: lead?.nombre || '',
botStep: lead?.botStep || 'nuevo',
viable: lead?.viable ?? null,
};
this.leadCache.set(leadId, ctx);
}
return ctx;
}
private async procesarMensaje(msg: any): Promise<void> {
const jid = msg.key.remoteJid!;
if (jid.includes('@g.us')) return;
if (jid.includes("@g.us")) return;
const telefono = jid.split("@")[0];
const telefono = jid.split('@')[0];
try {
let lead = await this.leadsService.findOrCreate(telefono);
const ctx = await this.getOrCreateContext(telefono, jid);
if (!ctx) return;
if (ESTADOS_TERMINALES.includes(lead.estado_actual)) {
this.logger.log(
`Lead id=${lead.id} en estado=${lead.estado_actual}. Ignorando.`,
);
return;
}
const primerMensajeDeUsuario = !this.jidToLeadId.has(jid);
let textoNormalizado = "";
let textoNormalizado = '';
const msgContent = normalizeMessageContent(msg.message);
if (!msgContent) return;
if (msgContent.conversation || msgContent.extendedTextMessage) {
textoNormalizado =
msgContent.conversation || msgContent.extendedTextMessage?.text || "";
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 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(
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 mimeType = audioMeta.mimetype || 'audio/ogg; codecs=opus';
this.logger.log(`[AUDIO] Recibido — lead=${ctx.leadId}`);
if (!this.sock) return;
const buffer = await downloadMediaMessage(
msg as any,
"buffer",
{},
{
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
},
);
const mimeType = msgContent.imageMessage.mimetype || "image/jpeg";
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
});
const audioBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
textoNormalizado = await this.mediaService.transcribirAudio(audioBuffer, mimeType);
} else if (msgContent.imageMessage) {
this.logger.log(`Imagen recibida de lead ${ctx.leadId}`);
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.isBuffer(buffer) ? buffer : Buffer.from(buffer),
mimeType,
lead.estado_actual,
'en_proceso',
);
if (msgContent.imageMessage.caption) {
textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`;
}
} else {
this.logger.log(
`Tipo de mensaje no soportado de lead id=${lead.id}. Ignorando.`,
);
this.logger.log(`Tipo de mensaje no soportado de lead ${ctx.leadId}. Ignorando.`);
return;
}
if (!textoNormalizado.trim()) return;
this.logger.log(`USUARIO [${telefono}]: ${textoNormalizado}`);
await this.conversacionService.guardarMensaje(
lead.id,
"user",
if (primerMensajeDeUsuario) {
await this.api.registrarIntento(ctx.leadId, 'whatsapp', 1, 'exitoso', true);
}
if (msgContent.imageMessage) {
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
});
const base64 = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64');
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
await this.api.enviarIngesta(ctx.leadId, [{
tipo: 'foto',
imagen: `data:${mimeType};base64,${base64}`,
zona: 'otro',
momento: 'antes',
}]);
}
await this.conversacionService.guardarMensaje(ctx.leadId, 'user', textoNormalizado, {
botStep: ctx.botStep,
});
const historial = await this.conversacionService.obtenerHistorialComoMessages(ctx.leadId);
const leadParaClaude = {
id: ctx.leadId,
telefono: ctx.telefono,
nombre: ctx.nombre,
estado_actual: ctx.botStep || 'nuevo',
espacio: null as string | null,
rango_m2: null as string | null,
estilo: null as string | null,
urgencia: null as string | null,
presupuesto_declarado: null as string | null,
viable: ctx.viable as boolean | null,
email: null as string | null,
};
const { respuesta, entidad, viable, nuevoEstado } = await this.claudeService.llamarClaude(
leadParaClaude as any,
historial.slice(0, -1),
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}`);
if (
(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 ((entidad && Object.keys(entidad).length > 0) || nuevoEstado || viable !== undefined) {
const entidadMap: Record<string, unknown> = {};
if (entidad) {
for (const [k, v] of Object.entries(entidad)) {
const mapped = this.mapearCampoALegacy(k);
entidadMap[mapped] = v;
}
}
await this.leadsService.persistirTurno(ctx.leadId, entidadMap, { nuevoEstado, viable });
if (nuevoEstado) ctx.botStep = nuevoEstado;
if (viable !== undefined) ctx.viable = viable;
this.logger.log(`Lead ${ctx.leadId} persistido — estado=${nuevoEstado || ctx.botStep}`);
}
await this.conversacionService.guardarMensaje(
lead.id,
"assistant",
respuesta,
);
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', respuesta, {
botStep: ctx.botStep,
});
await this.enviarMensaje(jid, respuesta);
} catch (error) {
this.logger.error(
`Error procesando mensaje de ${telefono}: ${error.message}`,
error.stack,
);
} catch (error: any) {
this.logger.error(`Error procesando mensaje de ${telefono}: ${error.message}`, error.stack);
}
}
private mapearCampoALegacy(campo: string): string {
const map: Record<string, string> = {
espacio: 'espacio',
rango_m2: 'rangoM2',
estilo: 'estilo',
urgencia: 'urgencia',
presupuesto_declarado: 'presupuestoDeclarado',
nombre: 'nombre',
};
return map[campo] || campo;
}
async enviarMensaje(jid: string, texto: string): Promise<void> {
if (!this.sock) return;
try {
const jidPresencia = jid.includes("@lid")
? `${jid.split("@")[0]}@s.whatsapp.net`
const jidPresencia = jid.includes('@lid')
? `${jid.split('@')[0]}@s.whatsapp.net`
: jid;
await this.sock.sendPresenceUpdate("composing", jidPresencia);
await this.sock.sendPresenceUpdate('composing', jidPresencia);
await this.delay(this.calcularDelayEscritura(texto.length));
await this.sock.sendPresenceUpdate("paused", jidPresencia);
await this.sock.sendPresenceUpdate('paused', jidPresencia);
const safeSock = wrapSocket(this.sock);
await safeSock.sendMessage(jid, { text: texto });
this.logger.log(`Mensaje enviado a ${jid}`);
} catch (error) {
} catch (error: any) {
this.logger.error(`Error enviando mensaje a ${jid}: ${error.message}`);
}
}
async enviarApertura(
telefono: string,
mensajeApertura: string,
): Promise<void> {
const jid = `${telefono}@s.whatsapp.net`;
await this.enviarMensaje(jid, mensajeApertura);
}
isConectado(): boolean {
return this.sock !== null;
}