Logia de agente de Whatsapp

This commit is contained in:
unknown
2026-05-31 22:02:58 -04:00
parent aa7555b49d
commit ef78d9a14c
28 changed files with 12736 additions and 0 deletions

4
.gitignore vendored
View File

@@ -1,2 +1,6 @@
*.ps1
zips/
node_modules/
.next/
next-env.d.ts

View File

@@ -0,0 +1,3 @@
OPENROUTER_API_KEY=
MODEL=
DATABASE_URL=

5
mvp/Whatsapp-bot/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.log
auth_info_baileys/

129
mvp/Whatsapp-bot/README.md Normal file
View File

@@ -0,0 +1,129 @@
# Reformix Luisa Bot 🤖
Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de forma conversacional, recoge 5 datos clave y cierra el flujo según el flag viable/no_viable.
## Stack
- **NestJS** — framework principal
- **Baileys** — conexión con WhatsApp (sin API oficial)
- **PostgreSQL** — base de datos via TypeORM
- **Claude 4.5** via **OpenRouter** — LLM con soporte de texto, audio e imagen
## Estructura del proyecto
```
/src
/whatsapp ← Módulo Baileys: conexión, QR, recepción y envío
/leads ← Módulo de leads: CRUD y lógica de estados
/conversacion ← Módulo de historial de mensajes por lead
/scheduler ← Cron cada 5 min: dispara apertura a leads nuevos
/claude ← Construye el contexto y llama a Claude 4.5
/media ← Procesa audio e imagen antes de pasar a Claude
/prompts
luisa_core.md ← Identidad y personalidad de Luisa ← RELLENAR
luisa_flujo.md ← Flujo de cualificación paso a paso ← RELLENAR
luisa_casos.md ← Casos edge y ejemplos ← RELLENAR
```
## Configuración rápida
### 1. Variables de entorno
```bash
cp .env.example .env
```
Edita `.env`:
```env
OPENROUTER_API_KEY=sk-or-...
MODEL=anthropic/claude-sonnet-4-5
DATABASE_URL=postgresql://user:password@localhost:5432/reformix_luisa
```
### 2. Base de datos
El proyecto usa `synchronize: true` en modo desarrollo, TypeORM creará las tablas automáticamente al arrancar.
En producción, desactiva `synchronize` y usa migrations:
```bash
npm run migration:generate
npm run migration:run
```
### 3. Prompts de Luisa
Rellena los 3 archivos en `/prompts` antes de arrancar:
- `luisa_core.md` — identidad, tono, límites
- `luisa_flujo.md` — estados, preguntas por estado, condiciones de avance
- `luisa_casos.md` — casos edge, fallbacks, ejemplos de conversación
### 4. Arrancar
```bash
npm install
npm run start:dev
```
Escanea el **QR** que aparece en la terminal con WhatsApp.
Luisa queda conectada y lista.
## Flujo de mensajes
```
Mensaje entrante (texto / audio / imagen)
Identificar lead por teléfono (crear si no existe)
Si audio → Claude 4.5 transcripción
Si imagen → Claude 4.5 Vision (prompt según estado)
Si texto → directo
Guardar mensaje usuario en DB
Construir contexto: estado, datos del lead, historial, prompts MD
Llamar Claude 4.5 via OpenRouter
Extraer entidades del turno → actualizar lead en DB
Evaluar flag viable → cambiar estado si aplica
Guardar respuesta de Claude en DB
Enviar respuesta por Baileys
```
## Scheduler (cron cada 5 min)
- Busca leads con `estado_actual = 'nuevo'`
- Marca como `en_proceso` antes de actuar
- Genera y envía el mensaje de APERTURA de Luisa
- Ignora leads en `completado`, `no_viable`, `perdido`
- Marca como `perdido` leads en `en_proceso` sin actividad > 48h
## Estados del lead
| Estado | Descripción |
|--------|-------------|
| `nuevo` | Lead creado, aún no contactado |
| `en_proceso` | Luisa le ha enviado el primer mensaje |
| `recopilando_datos` | Conversación activa |
| `completado` | Todos los datos recogidos, viable=true |
| `no_viable` | Lead descartado, viable=false |
| `perdido` | Sin actividad > 48h |
## Qué NO hace este servicio
- No genera el presupuesto (lo hace otro worker)
- No renderiza el PDF
- No envía la URL (la inserta el worker en `url_presupuesto`)
- No tiene panel del reformista
---
Desarrollado para Reformix © 2025

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11378
mvp/Whatsapp-bot/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
{
"name": "reformix-luisa-bot",
"version": "1.0.0",
"description": "Agente WhatsApp Luisa para Reformix cualificacion de leads de reforma",
"author": "Reformix",
"private": true,
"license": "MIT",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"migration:generate": "npm run typeorm -- migration:generate -d src/data-source.ts",
"migration:run": "npm run typeorm -- migration:run -d src/data-source.ts"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/typeorm": "^10.0.0",
"@whiskeysockets/baileys": "^6.7.9",
"axios": "^1.7.0",
"dotenv": "^16.4.0",
"form-data": "^4.0.1",
"pg": "^8.12.0",
"pino": "^9.3.2",
"qrcode-terminal": "^0.12.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.21",
"@types/node": "^20.14.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.0",
"prettier": "^3.3.0",
"ts-jest": "^29.2.0",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.0"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,31 @@
# Luisa — Casos edge
## Desvio del flujo
El usuario pregunta algo fuera del estado actual:
"Cuando terminemos te cuento todo con detalle. Seguimos?"
## Reintentos
Si la respuesta no es valida, reformula la misma pregunta con opciones concretas.
Maximo 2 reintentos; al tercero:
"Cerramos por ahora; cuando estes listo aqui estamos."
## Inactividad
- 24h sin respuesta: "Hola [nombre], quedamos a medias; cuando quieras seguimos con tu presupuesto."
- 48h sin respuesta: cerrar con estado perdido, no enviar mensaje.
## Media
**Audio:** Claude lo transcribe y trata como texto; si no entiende: "No te escuche bien, puedes repetirlo?"
**Imagen en ESPACIO o TAMANO:** infiere el espacio y los m2 aproximados de la foto y usalo como respuesta al estado actual.
**Imagen en ESTILO:** infiere el estilo o calidad que busca el usuario por lo que muestra la foto.
**Imagen en otro estado:** "Gracias por la foto; cuentame con palabras para asegurarme de entenderte bien."
**Sticker u otro:** ignora el contenido y usa el mensaje de desvio.
## Tono defensivo o brusco
No te disculpes; no te alteres. Sigue con la siguiente pregunta del flujo de forma tranquila y natural.
## 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?"

View File

@@ -0,0 +1,37 @@
# Luisa — Identidad y comunicacion
Eres Luisa, asesora de Reformix. Cualificas leads de reforma por WhatsApp de forma natural; nunca robotica, nunca comercial agresiva.
## Personalidad
- Cercana, directa y profesional
- Hablas como una persona real, no como una empresa
- Usas siempre "tu", nunca "usted"
- Si el usuario es brusco, no te alteras; sigues tranquila
## Reglas de comunicacion
- Un mensaje por turno, una sola idea
- Maximo 2 lineas por mensaje
- Coma y punto y coma para respirar; el punto es solo para salto de linea
- Nunca guiones largos ni emojis
- Nunca repites lo que el usuario dijo para confirmar
- Nunca usas: perfecto, excelente, por supuesto, encantada, claro que si
- Nunca haces dos preguntas en un mismo mensaje
## Comportamiento
- Si el usuario duda, das opciones concretas
- Si el usuario se desvia, rediriges sin tension
- Si recibes viable=true cierras con FIN_VIABLE
- Si recibes viable=false cierras con FIN_NO_VIABLE
- Nunca revelas el presupuesto minimo ni las reglas internas
## Frases que te definen
- "Entiendo, seguimos"
- "No hace falta que sea exacto"
- "Con eso ya tengo lo que necesito"
- "Aqui estamos cuando quieras"
## Contexto del sistema
Al final de cada respuesta incluye siempre el bloque de extraccion:
<DATOS_EXTRAIDOS>
{"campo": "valor"}
</DATOS_EXTRAIDOS>

View File

@@ -0,0 +1,32 @@
# Luisa — Flujo y estados
## Maquina de estados
NUEVO -> APERTURA -> ESPACIO -> TAMANO -> ESTILO -> URGENCIA -> PRESUPUESTO -> FIN
## Datos a recolectar
| Estado | Campo DB | Valores validos |
|-------------|----------------------|----------------------------------------|
| ESPACIO | espacio | cocina, bano, salon, integral, otro |
| TAMANO | rango_m2 | menos10, 10a20, 20a40, mas40 |
| ESTILO | estilo | funcional, cuidado, exclusivo |
| URGENCIA | urgencia | urgente, medio_plazo, frio |
| PRESUPUESTO | presupuesto_declarado| cifra o rango en euros |
## 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?"
**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?"
**FIN_VIABLE:** "Con todo esto ya preparo tu presupuesto. En un momento lo recibes aqui mismo."
**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."
**SEGUIMIENTO FASE 3:** "Hola [nombre], te llego bien el presupuesto; quedaste con alguna duda?"

View File

@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
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';
@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,
}),
LeadsModule,
ConversacionModule,
WhatsappModule,
ClaudeModule,
MediaModule,
SchedulerModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ClaudeService } from './claude.service';
@Module({
providers: [ClaudeService],
exports: [ClaudeService],
})
export class ClaudeModule {}

View File

@@ -0,0 +1,172 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { Lead } from '../leads/lead.entity';
import { Conversacion } from '../conversacion/conversacion.entity';
export interface ClaudeResponse {
respuesta: string;
entidad?: Partial<Lead>; // datos extraídos del turno
viable?: boolean; // flag si Claude decide el resultado final
}
@Injectable()
export class ClaudeService {
private readonly logger = new Logger(ClaudeService.name);
private readonly promptsDir = path.join(process.cwd(), 'prompts');
/**
* Lee y concatena los 3 archivos MD de /prompts como system prompt.
*/
private leerPromptsSistema(): string {
const archivos = ['luisa_core.md', 'luisa_flujo.md', 'luisa_casos.md'];
const partes: string[] = [];
for (const archivo of archivos) {
const rutaCompleta = path.join(this.promptsDir, archivo);
try {
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}`);
}
}
return partes.join('\n');
}
/**
* Serializa los datos actuales del lead para el contexto de Claude.
*/
private serializarLead(lead: Lead): string {
return [
`- ID: ${lead.id}`,
`- Telefono: ${lead.telefono}`,
`- Estado actual: ${lead.estado_actual}`,
`- Nombre: ${lead.nombre || 'no capturado'}`,
`- Email: ${lead.email || 'no capturado'}`,
`- Espacio: ${lead.espacio || 'no capturado'}`,
`- Rango m2: ${lead.rango_m2 || 'no capturado'}`,
`- Estilo: ${lead.estilo || 'no capturado'}`,
`- Urgencia: ${lead.urgencia || 'no capturado'}`,
`- Presupuesto declarado: ${lead.presupuesto_declarado || 'no capturado'}`,
`- Viable: ${lead.viable !== null && lead.viable !== undefined ? lead.viable : 'pendiente'}`,
].join('\n');
}
/**
* Llama a Claude 4.5 via OpenRouter con el contexto completo del lead.
* Devuelve la respuesta de Luisa y los datos extraídos del turno.
*
* @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(
lead: Lead,
historial: Array<{ role: string; content: string }>,
mensajeActual: string,
): Promise<ClaudeResponse> {
const systemPrompt = this.leerPromptsSistema();
const contextoDeLead = `
## Contexto del lead actual
${this.serializarLead(lead)}
`;
const systemFinal = `${systemPrompt}
${contextoDeLead}
## Instrucciones de extracción de datos
Al responder, incluye al final de tu mensaje un bloque JSON con el formato exacto (sin markdown, sin comillas extras):
<DATOS_EXTRAIDOS>
{
"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 = [
...historial,
{ role: 'user', content: mensajeActual },
];
try {
const response = await axios.post(
'https://openrouter.ai/api/v1/chat/completions',
{
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
messages,
system: systemFinal,
max_tokens: 1024,
temperature: 0.7,
},
{
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Luisa Bot',
},
},
);
const contenidoCompleto: string =
response.data.choices?.[0]?.message?.content || '';
// Separar la respuesta visible del bloque de datos extraídos
const regexDatos = /<DATOS_EXTRAIDOS>([\s\S]*?)<\/DATOS_EXTRAIDOS>/;
const match = contenidoCompleto.match(regexDatos);
let respuesta = contenidoCompleto
.replace(regexDatos, '')
.trim();
let entidad: Partial<Lead> = {};
let viableFlag: boolean | undefined = undefined;
if (match) {
try {
const datos = JSON.parse(match[1].trim());
// Solo incluir campos no nulos
Object.entries(datos).forEach(([k, v]) => {
if (v !== null && k !== 'viable') {
(entidad as Record<string, unknown>)[k] = v;
}
});
if (datos.viable !== null && datos.viable !== undefined) {
viableFlag = datos.viable;
}
} catch {
this.logger.warn('No se pudo parsear DATOS_EXTRAIDOS');
}
}
return { respuesta, entidad, viable: viableFlag };
} catch (error) {
this.logger.error(
`Error llamando a Claude via OpenRouter: ${error.message}`,
error.response?.data,
);
throw error;
}
}
}

View File

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,11 @@
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],
})
export class ConversacionModule {}

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Conversacion, RolMensaje } from './conversacion.entity';
@Injectable()
export class ConversacionService {
constructor(
@InjectRepository(Conversacion)
private readonly convRepo: Repository<Conversacion>,
) {}
async guardarMensaje(
leadId: number,
rol: RolMensaje,
mensaje: string,
): Promise<Conversacion> {
const entry = this.convRepo.create({ lead_id: leadId, rol, mensaje });
return this.convRepo.save(entry);
}
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,
}));
}
}

View File

@@ -0,0 +1,60 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
export type EstadoLead =
| 'nuevo'
| 'en_proceso'
| '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

@@ -0,0 +1,11 @@
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],
})
export class LeadsModule {}

View File

@@ -0,0 +1,85 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan } from 'typeorm';
import { Lead, EstadoLead } from './lead.entity';
@Injectable()
export class LeadsService {
private readonly logger = new Logger(LeadsService.name);
constructor(
@InjectRepository(Lead)
private readonly leadRepo: Repository<Lead>,
) {}
/**
* 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): Promise<Lead> {
lead.estado_actual = estado;
return this.leadRepo.save(lead);
}
/**
* 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> {
await this.leadRepo.update(leadId, datos);
return this.leadRepo.findOne({ where: { id: leadId } });
}
async marcarViable(lead: Lead, viable: boolean): Promise<Lead> {
lead.viable = viable;
lead.estado_actual = viable ? 'completado' : 'no_viable';
return this.leadRepo.save(lead);
}
/**
* 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);
}
}

View File

@@ -0,0 +1,19 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as dotenv from 'dotenv';
dotenv.config();
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log', 'debug'],
});
const port = process.env.PORT || 3000;
await app.listen(port);
console.log(`🚀 Reformix Luisa Bot corriendo en el puerto ${port}`);
console.log(`📡 Esperando conexion de WhatsApp...`);
}
bootstrap();

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MediaService } from './media.service';
@Module({
providers: [MediaService],
exports: [MediaService],
})
export class MediaModule {}

View File

@@ -0,0 +1,171 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { EstadoLead } from '../leads/lead.entity';
@Injectable()
export class MediaService {
private readonly logger = new Logger(MediaService.name);
private readonly OPENROUTER_URL =
'https://openrouter.ai/api/v1/chat/completions';
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',
};
}
/**
* Transcribe un audio enviándolo a Claude 4.5 como base64.
* Baileys entrega el buffer del audio; lo convertimos a base64.
*
* @param audioBuffer Buffer del audio recibido por Baileys
* @param mimeType MIME type del audio (ej: audio/ogg; codecs=opus)
* @returns Texto transcrito, o el fallback si falla
*/
async transcribirAudio(
audioBuffer: Buffer,
mimeType = 'audio/ogg',
): Promise<string> {
const FALLBACK =
'No pude escuchar bien el audio. ¿Puedes escribirme lo que me querías contar?';
try {
const base64Audio = audioBuffer.toString('base64');
const response = await axios.post(
this.OPENROUTER_URL,
{
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Por favor, transcribe exactamente lo que se dice en este audio. Devuelve solo la transcripción, sin añadir nada más.',
},
{
type: 'image_url', // OpenRouter usa image_url para base64 de audio también
image_url: {
url: `data:${mimeType};base64,${base64Audio}`,
},
},
],
},
],
max_tokens: 512,
},
{ headers: this.headers },
);
const transcripcion: string =
response.data.choices?.[0]?.message?.content?.trim();
if (!transcripcion) {
this.logger.warn('Claude devolvió respuesta vacía para el audio');
return FALLBACK;
}
this.logger.log(
`Audio transcrito correctamente (${transcripcion.length} chars)`,
);
return transcripcion;
} catch (error) {
this.logger.error(
`Error transcribiendo audio: ${error.message}`,
error.response?.data,
);
return FALLBACK;
}
}
/**
* Infiere información de una imagen según el estado actual del lead.
* Útil para capturar espacios, materiales, estilos, etc.
*
* @param imagenBuffer Buffer de la imagen recibida por Baileys
* @param mimeType MIME type (ej: image/jpeg)
* @param estadoActual Estado del lead para adaptar el prompt de visión
* @returns Texto inferido, o el fallback si falla
*/
async inferirImagen(
imagenBuffer: Buffer,
mimeType = 'image/jpeg',
estadoActual: EstadoLead = 'en_proceso',
): Promise<string> {
const FALLBACK =
'Recibí tu imagen pero no pude analizarla bien. ¿Puedes describirme lo que muestra?';
const promptPorEstado: Record<string, string> = {
nuevo:
'Describe brevemente qué tipo de espacio se ve en esta imagen y sus características principales.',
en_proceso:
'Describe el espacio que aparece en la imagen: tipo de habitación, materiales, estado actual, tamaño aproximado.',
recopilando_datos:
'Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservación.',
completado:
'Describe lo que ves en esta imagen relacionado con reformas o diseño de interiores.',
no_viable:
'Describe brevemente qué muestra esta imagen.',
perdido:
'Describe brevemente qué muestra esta imagen.',
};
const promptDeVisión =
promptPorEstado[estadoActual] ||
'Describe qué 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 || 'anthropic/claude-sonnet-4-5',
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: promptDeVisión,
},
{
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 devolvió respuesta vacía 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,
);
return FALLBACK;
}
}
}

View File

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,86 @@
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,13 @@
import { Module } from '@nestjs/common';
import { WhatsappService } from './whatsapp.service';
import { LeadsModule } from '../leads/leads.module';
import { ConversacionModule } from '../conversacion/conversacion.module';
import { ClaudeModule } from '../claude/claude.module';
import { MediaModule } from '../media/media.module';
@Module({
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule],
providers: [WhatsappService],
exports: [WhatsappService],
})
export class WhatsappModule {}

View File

@@ -0,0 +1,252 @@
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
WASocket,
downloadMediaMessage,
proto,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import * as path from 'path';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pino = require('pino');
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 { Lead } from '../leads/lead.entity';
@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');
constructor(
private readonly leadsService: LeadsService,
private readonly conversacionService: ConversacionService,
private readonly claudeService: ClaudeService,
private readonly mediaService: MediaService,
) {}
async onModuleInit() {
await this.conectar();
}
async onModuleDestroy() {
if (this.sock) {
this.sock.end(undefined);
}
}
private async conectar() {
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
this.sock = makeWASocket({
auth: state,
printQRInTerminal: true,
logger: pino({ level: 'silent' }) as any,
});
this.sock.ev.on('creds.update', saveCreds);
this.sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
console.log('\n📲 Escanea el QR de arriba con WhatsApp\n');
}
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 está lista para recibir mensajes.',
);
}
});
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
if (type !== 'notify') return;
for (const msg of messages) {
if (msg.key.fromMe) continue; // ignorar mensajes propios
if (!msg.key.remoteJid) continue;
await this.procesarMensaje(msg);
}
});
}
/**
* Procesa un mensaje entrante de WhatsApp.
* Identifica el tipo (texto, audio, imagen), normaliza el contenido,
* consulta/crea el lead, llama a Claude y envía la respuesta.
*/
private async procesarMensaje(
msg: proto.IWebMessageInfo,
): Promise<void> {
const jid = msg.key.remoteJid!;
// Normalizar el número de teléfono (quitar el @s.whatsapp.net y el sufijo de grupo)
const telefono = jid.replace('@s.whatsapp.net', '').replace('@g.us', '');
try {
// 1. Identificar o crear el lead
const lead = await this.leadsService.findOrCreate(telefono);
// Ignorar leads ya terminados
if (['completado', 'no_viable', 'perdido'].includes(lead.estado_actual)) {
this.logger.log(
`Lead id=${lead.id} en estado=${lead.estado_actual}. Mensaje ignorado.`,
);
return;
}
// 2. Determinar el tipo de mensaje y normalizarlo a texto
let textoNormalizado = '';
const msgContent = msg.message;
if (!msgContent) return;
if (msgContent.conversation || msgContent.extendedTextMessage) {
// Texto plano
textoNormalizado =
msgContent.conversation ||
msgContent.extendedTextMessage?.text ||
'';
} else if (msgContent.audioMessage) {
// Audio → Claude transcripción
this.logger.log(`Audio recibido de lead id=${lead.id}. Transcribiendo...`);
const buffer = await downloadMediaMessage(msg, 'buffer', {});
const mimeType =
msgContent.audioMessage.mimetype || 'audio/ogg; codecs=opus';
textoNormalizado = await this.mediaService.transcribirAudio(
buffer as Buffer,
mimeType,
);
} else if (msgContent.imageMessage) {
// Imagen → Claude Vision
this.logger.log(
`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`,
);
const buffer = await downloadMediaMessage(msg, 'buffer', {});
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
textoNormalizado = await this.mediaService.inferirImagen(
buffer as Buffer,
mimeType,
lead.estado_actual,
);
// Si el lead envió un caption junto con la imagen, concatenarlo
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.`,
);
return;
}
if (!textoNormalizado.trim()) return;
// 3. Guardar el mensaje del usuario en historial
await this.conversacionService.guardarMensaje(
lead.id,
'user',
textoNormalizado,
);
// 4. Construir historial y llamar a Claude
const historial =
await this.conversacionService.obtenerHistorialComoMessages(lead.id);
const { respuesta, entidad, viable } = await this.claudeService.llamarClaude(
lead,
historial.slice(0, -1), // el último ya es el mensaje actual
textoNormalizado,
);
// 5. Actualizar datos del lead con lo extraído por Claude
if (entidad && Object.keys(entidad).length > 0) {
await this.leadsService.updateDatos(lead.id, entidad);
}
// 6. Manejar el flag viable
if (viable !== undefined && viable !== null) {
await this.leadsService.marcarViable(lead, viable);
this.logger.log(
`Lead id=${lead.id} marcado como viable=${viable}`,
);
} else {
// Avanzar estado si sigue en_proceso
if (lead.estado_actual === 'nuevo') {
await this.leadsService.updateEstado(lead, 'en_proceso');
}
}
// 7. Guardar respuesta de Claude en historial
await this.conversacionService.guardarMensaje(
lead.id,
'assistant',
respuesta,
);
// 8. Enviar respuesta por WhatsApp
await this.enviarMensaje(jid, respuesta);
} catch (error) {
this.logger.error(
`Error procesando mensaje de ${telefono}: ${error.message}`,
error.stack,
);
}
}
/**
* Envía un mensaje de texto por WhatsApp.
*/
async enviarMensaje(jid: string, texto: string): Promise<void> {
if (!this.sock) {
this.logger.error('Socket de WhatsApp no disponible');
return;
}
try {
await this.sock.sendMessage(jid, { text: texto });
this.logger.log(`Mensaje enviado a ${jid}`);
} catch (error) {
this.logger.error(`Error enviando mensaje a ${jid}: ${error.message}`);
}
}
/**
* Envía el mensaje de apertura de Luisa a un número de teléfono.
* Lo usa el Scheduler para disparar el primer contacto.
*/
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;
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": false,
"noEmit": true
},
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}