Merge branch 'main' of https://github.com/McGregory99/reformix-hackaton
# Conflicts: # .gitignore
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,8 @@
|
|||||||
*.ps1
|
*.ps1
|
||||||
zips/
|
zips/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
next-env.d.ts
|
||||||
|
|||||||
3
mvp/Whatsapp-bot/.env.example
Normal file
3
mvp/Whatsapp-bot/.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
OPENROUTER_API_KEY=
|
||||||
|
MODEL=
|
||||||
|
DATABASE_URL=
|
||||||
5
mvp/Whatsapp-bot/.gitignore
vendored
Normal file
5
mvp/Whatsapp-bot/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
auth_info_baileys/
|
||||||
129
mvp/Whatsapp-bot/README.md
Normal file
129
mvp/Whatsapp-bot/README.md
Normal 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
|
||||||
8
mvp/Whatsapp-bot/nest-cli.json
Normal file
8
mvp/Whatsapp-bot/nest-cli.json
Normal 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
11378
mvp/Whatsapp-bot/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
65
mvp/Whatsapp-bot/package.json
Normal file
65
mvp/Whatsapp-bot/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
mvp/Whatsapp-bot/prompts/luisa_casos.md
Normal file
31
mvp/Whatsapp-bot/prompts/luisa_casos.md
Normal 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?"
|
||||||
37
mvp/Whatsapp-bot/prompts/luisa_core.md
Normal file
37
mvp/Whatsapp-bot/prompts/luisa_core.md
Normal 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>
|
||||||
32
mvp/Whatsapp-bot/prompts/luisa_flujo.md
Normal file
32
mvp/Whatsapp-bot/prompts/luisa_flujo.md
Normal 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?"
|
||||||
33
mvp/Whatsapp-bot/src/app.module.ts
Normal file
33
mvp/Whatsapp-bot/src/app.module.ts
Normal 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 {}
|
||||||
8
mvp/Whatsapp-bot/src/claude/claude.module.ts
Normal file
8
mvp/Whatsapp-bot/src/claude/claude.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ClaudeService } from './claude.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [ClaudeService],
|
||||||
|
exports: [ClaudeService],
|
||||||
|
})
|
||||||
|
export class ClaudeModule {}
|
||||||
172
mvp/Whatsapp-bot/src/claude/claude.service.ts
Normal file
172
mvp/Whatsapp-bot/src/claude/claude.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
mvp/Whatsapp-bot/src/conversacion/conversacion.entity.ts
Normal file
33
mvp/Whatsapp-bot/src/conversacion/conversacion.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
11
mvp/Whatsapp-bot/src/conversacion/conversacion.module.ts
Normal file
11
mvp/Whatsapp-bot/src/conversacion/conversacion.module.ts
Normal 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 {}
|
||||||
41
mvp/Whatsapp-bot/src/conversacion/conversacion.service.ts
Normal file
41
mvp/Whatsapp-bot/src/conversacion/conversacion.service.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
60
mvp/Whatsapp-bot/src/leads/lead.entity.ts
Normal file
60
mvp/Whatsapp-bot/src/leads/lead.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
11
mvp/Whatsapp-bot/src/leads/leads.module.ts
Normal file
11
mvp/Whatsapp-bot/src/leads/leads.module.ts
Normal 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 {}
|
||||||
85
mvp/Whatsapp-bot/src/leads/leads.service.ts
Normal file
85
mvp/Whatsapp-bot/src/leads/leads.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
mvp/Whatsapp-bot/src/main.ts
Normal file
19
mvp/Whatsapp-bot/src/main.ts
Normal 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();
|
||||||
8
mvp/Whatsapp-bot/src/media/media.module.ts
Normal file
8
mvp/Whatsapp-bot/src/media/media.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [MediaService],
|
||||||
|
exports: [MediaService],
|
||||||
|
})
|
||||||
|
export class MediaModule {}
|
||||||
171
mvp/Whatsapp-bot/src/media/media.service.ts
Normal file
171
mvp/Whatsapp-bot/src/media/media.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
mvp/Whatsapp-bot/src/scheduler/scheduler.module.ts
Normal file
12
mvp/Whatsapp-bot/src/scheduler/scheduler.module.ts
Normal 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 {}
|
||||||
86
mvp/Whatsapp-bot/src/scheduler/scheduler.service.ts
Normal file
86
mvp/Whatsapp-bot/src/scheduler/scheduler.service.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
mvp/Whatsapp-bot/src/whatsapp/whatsapp.module.ts
Normal file
13
mvp/Whatsapp-bot/src/whatsapp/whatsapp.module.ts
Normal 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 {}
|
||||||
252
mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts
Normal file
252
mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
mvp/Whatsapp-bot/tsconfig.build.json
Normal file
8
mvp/Whatsapp-bot/tsconfig.build.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": false,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
21
mvp/Whatsapp-bot/tsconfig.json
Normal file
21
mvp/Whatsapp-bot/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user