Configuracion de agente de whastapp paratrabajar con la estructura propuesta
This commit is contained in:
572
docs/arquitectura-integracion.md
Normal file
572
docs/arquitectura-integracion.md
Normal file
@@ -0,0 +1,572 @@
|
||||
# Arquitectura de Integración — Reformix
|
||||
|
||||
## Índice
|
||||
|
||||
1. [Visión general del sistema](#1-visión-general-del-sistema)
|
||||
2. [Landing pública y captura del lead (sitio web)](#2-landing-pública-y-captura-del-lead-sitio-web)
|
||||
3. [El funnel B2C — pipeline de 7 pasos](#3-el-funnel-b2c--pipeline-de-7-pasos)
|
||||
4. [Elección de canal: formulario, llamada o WhatsApp](#4-elección-de-canal-formulario-llamada-o-whatsapp)
|
||||
5. [Sistema de webhooks salientes (app → bot/worker)](#5-sistema-de-webhooks-salientes-app--botworker)
|
||||
6. [Sistema de endpoints de bot (app ← bot/worker)](#6-sistema-de-endpoints-de-bot-app--botworker)
|
||||
7. [El agente de WhatsApp (Luisa)](#7-el-agente-de-whatsapp-luisa)
|
||||
8. [Workers: render de imágenes y presupuesto](#8-workers-render-de-imágenes-y-presupuesto)
|
||||
9. [El presupuesto como entregable final](#9-el-presupuesto-como-entregable-final)
|
||||
10. [Diagrama de flujo completo](#10-diagrama-de-flujo-completo)
|
||||
11. [Estado actual vs lo que falta](#11-estado-actual-vs-lo-que-falta)
|
||||
12. [Guía de conexión: cómo integrar Luisa + Workers con la app](#12-guía-de-conexión-cómo-integrar-luisa--workers-con-la-app)
|
||||
|
||||
---
|
||||
|
||||
## 1. Visión general del sistema
|
||||
|
||||
Reformix es un SaaS multi-tenant para empresas de reformas. Tiene **dos caras**:
|
||||
|
||||
- **B2B (reformista):** el reformista se registra, configura su perfil, catálogo de materiales, precios, y pone un widget en su web.
|
||||
- **B2C (cliente final):** el cliente llega a la landing del reformista, pide presupuesto, sube fotos, y recibe un presupuesto con render "antes/después" en < 7 minutos.
|
||||
|
||||
### Componentes del sistema
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SITIO WEB (Next.js) │
|
||||
│ Landing → Formulario → Elección canal → Pipeline → Entrega │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ App Reformix (mvp/b2c/) │ │
|
||||
│ │ - Landing pública /[slug] │ │
|
||||
│ │ - Funnel B2C /solicitud/[id]/* │ │
|
||||
│ │ - Panel reformista /panel/* │ │
|
||||
│ │ - API endpoints para bot (mvp/b2c/src/app/api/leads/) │ │
|
||||
│ │ - Motor de presupuesto (mvp/b2c/src/budget/) │ │
|
||||
│ │ - Generación de PDF (mvp/b2c/src/lib/pdf/) │ │
|
||||
│ │ - Envío de email (mvp/b2c/src/lib/email/) │ │
|
||||
│ │ - Webhooks salientes (mvp/b2c/src/lib/webhooks.ts) │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ AGENTE WHATSAPP (Luisa) — EXTERNO │
|
||||
│ (mvp/Whatsapp-bot/) │
|
||||
│ - Conexión WhatsApp via Baileys │
|
||||
│ - Pipeline Claude 4-capas para cualificar leads │
|
||||
│ - DEBE usar API HTTP de la app (hoy escribe directo a BD) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ WORKERS (Render + Análisis) — NO IMPLEMENTADO AÚN │
|
||||
│ - Generación de renders "después" (Nano Banana 2 / Image 2) │
|
||||
│ - Análisis de fotos con IA │
|
||||
│ - DEBE recibir webhook PERFIL_WEBHOOK_URL y devolver vía API │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Landing pública y captura del lead (sitio web)
|
||||
|
||||
### Flujo de captura
|
||||
|
||||
```
|
||||
Usuario llega a /[slug] (landing del reformista)
|
||||
│
|
||||
▼
|
||||
Rellena formulario Hero:
|
||||
- nombre + email + teléfono
|
||||
- consentimiento privacidad + contratación (RGPD obligatorio)
|
||||
│
|
||||
▼
|
||||
crearLead(slug, data) → Server Action
|
||||
│
|
||||
├── Valida con Zod schema
|
||||
├── Busca tenant por slug
|
||||
├── INSERT en leads (pipelineStage: 'form_completado', estado: 'nuevo')
|
||||
└── INSERT en leadPipelineEventos (stage: 'form_completado')
|
||||
```
|
||||
|
||||
### Lo que crea la base de datos
|
||||
|
||||
El lead se crea con:
|
||||
|
||||
- `id` (UUID) — **este es el leadId que usará todo el sistema**
|
||||
- `tenantId` (UUID) — referencia al reformista
|
||||
- `nombre`, `email`, `telefono` — datos del cliente
|
||||
- `pipelineStage: 'form_completado'` — dónde está en el pipeline
|
||||
- `estado: 'nuevo'` — estado comercial
|
||||
|
||||
**Importante:** El lead NO tiene aún `tipoReforma`, `m2Suelo`, `calidadGlobal`, etc. Esos se rellenan después, cuando el cliente pasa por el canal elegido.
|
||||
|
||||
---
|
||||
|
||||
## 3. El funnel B2C — pipeline de 7 pasos
|
||||
|
||||
Definido por el enum `pipelineStage` en `src/db/schema.ts`:
|
||||
|
||||
| # | Stage | Qué ocurre | Quién lo dispara |
|
||||
| --- | ---------------------- | ---------------------------------------------- | ------------------------------------------------------- |
|
||||
| 1 | `form_completado` | Lead creado con datos básicos | Server Action `crearLead()` |
|
||||
| 2 | `fotos_subidas` | Cliente describe reforma + sube fotos por zona | Server Action `guardarDetallesYFotos()` o API `ingesta` |
|
||||
| 3 | `prellamada_enviada` | Notificación SMS/WhatsApp previa a llamada | Orchestrator `procesarLead()` o bot |
|
||||
| 4 | `llamada_completada` | Agente IA (Retell) cualifica al lead | Orchestrator (simulado) o webhook Retell (real) |
|
||||
| 5 | `render_generado` | Render "después" generado por IA | Orchestrator (simulado con imagen demo) |
|
||||
| 6 | `presupuesto_generado` | Presupuesto calculado con motor real | Orchestrator `procesarLead()` |
|
||||
| 7 | `whatsapp_entregado` | PDF entregado al cliente | `finalizarYEntregar()` |
|
||||
|
||||
### Pipeline automático (`procesarLead`)
|
||||
|
||||
Cuando el cliente completa el formulario detallado (con zonas, fotos, etc.), la app ejecuta:
|
||||
|
||||
```typescript
|
||||
guardarDetallesYFotos(leadId, formData)
|
||||
├── Guarda fotos (momento: 'antes') en leadFotos
|
||||
├── Guarda notas en leadNotas
|
||||
├── Calcula tipoReforma, m2Suelo, calidadGlobal desde las zonas
|
||||
├── UPDATE lead (pipelineStage: 'fotos_subidas')
|
||||
│
|
||||
└── procesarLead(leadId) ← ORQUESTRADOR
|
||||
├── Paso 4: Evento prellamada_enviada
|
||||
├── Paso 5: Llamada Retell (real si configurado, sino simulada con transcript ficticio)
|
||||
├── Paso 6a: Render demo (imagen estática, NO IA real)
|
||||
├── Paso 6b: Presupuesto REAL con motor (computeBudget)
|
||||
├── UPDATE lead (pipelineStage: 'presupuesto_generado')
|
||||
└── Paso 7: Si envio=automatico → lead pasa a 'whatsapp_entregado'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Elección de canal: formulario, llamada o WhatsApp
|
||||
|
||||
Después de crear el lead, el cliente llega a `/solicitud/[id]/` donde elige cómo continuar:
|
||||
|
||||
### Canal formulario (`/solicitud/[id]/formulario`)
|
||||
|
||||
El cliente rellena un formulario multi-zona: tipo de reforma, m², calidad, notas, sube fotos. Esto dispara `guardarDetallesYFotos()` que ejecuta el pipeline completo (incluyendo llamada simulada, render demo, presupuesto real).
|
||||
|
||||
### Canal llamada (`/solicitud/[id]/llamada`)
|
||||
|
||||
El cliente pide que le llamen (ahora o programado). Se le envía un email con enlace para subir fotos después. Dispara `pedirLlamada()` que inicia llamada Retell saliente (si configurado). El cliente recibe la llamada del agente IA, y después puede subir fotos via el enlace del email.
|
||||
|
||||
### Canal WhatsApp (`/solicitud/[id]/whatsapp`)
|
||||
|
||||
El cliente elige continuar por WhatsApp. La app dispara `iniciarWhatsapp()` que:
|
||||
|
||||
```typescript
|
||||
iniciarWhatsapp(leadId)
|
||||
└── POST a WHATSAPP_START_WEBHOOK_URL
|
||||
Payload: { leadId, telefono, nombre, empresa }
|
||||
→ El bot de WhatsApp (Luisa) recibe esto y empieza la conversación
|
||||
```
|
||||
|
||||
**Este es el punto de entrada del bot de WhatsApp.** El bot recibe el `leadId` y a partir de ahí debe escribir los datos extraídos de la conversación usando los endpoints de la app.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sistema de webhooks salientes (app → bot/worker)
|
||||
|
||||
La app envía 3 señales HTTP a sistemas externos. Todas son **best-effort** (nunca lanzan error, devuelven boolean).
|
||||
|
||||
### 5.1 WHATSAPP_START_WEBHOOK_URL — Arranque de conversación WhatsApp
|
||||
|
||||
**Disparado por:** `iniciarWhatsapp()` (cuando el lead elige canal WhatsApp)
|
||||
|
||||
```json
|
||||
POST {url}
|
||||
{
|
||||
"leadId": "uuid",
|
||||
"telefono": "+34...",
|
||||
"nombre": "...",
|
||||
"empresa": "Reformas Ejemplo"
|
||||
}
|
||||
```
|
||||
|
||||
**La app espera que:** el bot de WhatsApp reciba esto y comience la conversación con el lead. El bot debe usar el `leadId` para escribir los datos vía los endpoints de la app.
|
||||
|
||||
### 5.2 PERFIL_WEBHOOK_URL — Perfil completo para generar renders
|
||||
|
||||
**Disparado por:**
|
||||
|
||||
- `señalarPerfilCompleto()` en `guardarDetallesYFotos()` (formulario)
|
||||
- `señalarPerfilCompleto()` en `subirFotos()` (fotos por email)
|
||||
- API `ingesta` con flag `perfilCompleto: true` (bot/worker)
|
||||
|
||||
```json
|
||||
POST {url}
|
||||
{
|
||||
"leadId": "uuid",
|
||||
"cliente": { "nombre": "...", "telefono": "...", "email": "...", "provincia": "..." },
|
||||
"reforma": { "tipo": "cocina", "m2Suelo": 12, "calidad": "media",
|
||||
"estructural": false, "urgencia": "media", "presupuestoTarget": 800000 },
|
||||
"empresa": { "tenantId": "uuid", "nombre": "Reformas Ejemplo" },
|
||||
"zonas": [
|
||||
{ "zona": "cocina",
|
||||
"notas": ["encimera de cuarzo"],
|
||||
"fotos": { "antes": ["data:image/..."], "despues": [] } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**La app espera que:** el worker externo genere renders "después" a partir de las fotos "antes", y los devuelva haciendo POST al endpoint `/api/leads/:id/ingesta` con `momento: "despues"` y opcionalmente `finalizar: true`.
|
||||
|
||||
### 5.3 WHATSAPP_WEBHOOK_URL — Entrega del PDF
|
||||
|
||||
**Disparado por:** `finalizarYEntregar()` (cuando el PDF está listo)
|
||||
|
||||
```json
|
||||
POST {url}
|
||||
{
|
||||
"leadId": "uuid",
|
||||
"telefono": "+34...",
|
||||
"nombre": "...",
|
||||
"empresa": "Reformas Ejemplo",
|
||||
"pdfBase64": "JVBERi0xLj...",
|
||||
"filename": "presupuesto-nombre.pdf"
|
||||
}
|
||||
```
|
||||
|
||||
**La app espera que:** el bot de WhatsApp reciba esto y envíe el PDF al cliente por WhatsApp.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sistema de endpoints de bot (app ← bot/worker)
|
||||
|
||||
La app expone 5 endpoints bajo `/api/leads/:id/`. Todos requieren `Authorization: Bearer <FUNNEL_API_KEY>`.
|
||||
|
||||
| Endpoint | Qué hace | Tabla que escribe |
|
||||
| ---------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `POST /api/leads/:id/conversacion` | Guarda un turno del chat | `conversacion_whatsapp` |
|
||||
| `POST /api/leads/:id/perfil` | Actualiza datos extraídos del lead | `leads` (campos: espacio, rangoM2, estilo, tipoReforma, m2Suelo, etc.) |
|
||||
| `POST /api/leads/:id/calificacion` | Upsert de calificación | `lead_calificacion` |
|
||||
| `POST /api/leads/:id/intento` | Registra intento de contacto | `intentos_contacto` |
|
||||
| `POST /api/leads/:id/ingesta` | Sube fotos/notas + flags de perfilCompleto/finalizar | `lead_fotos`, `lead_notas` |
|
||||
|
||||
### Flujo de uso típico del bot
|
||||
|
||||
```
|
||||
1. Bot recibe WHATSAPP_START_WEBHOOK → leadId, telefono, nombre
|
||||
2. Bot inicia conversación por WhatsApp
|
||||
3. Por cada interacción:
|
||||
a. Bot llama POST /conversacion (guarda el turno)
|
||||
b. Bot llama POST /perfil (actualiza datos extraídos: espacio, m2, estilo, etc.)
|
||||
c. Cuando tiene datos suficientes, llama POST /calificacion
|
||||
d. Cuando pide fotos, el cliente las envía, bot las guarda vía POST /ingesta
|
||||
4. Cuando el perfil está completo:
|
||||
- Bot marca perfilCompleto: true en POST /ingesta
|
||||
- Esto dispara PERFIL_WEBHOOK_URL → worker genera renders
|
||||
- Worker devuelve renders vía POST /ingesta con momento: "despues" + finalizar: true
|
||||
- finalizar:true dispara WHATSAPP_WEBHOOK_URL → bot recibe PDF y lo envía al cliente
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. El agente de WhatsApp (Luisa)
|
||||
|
||||
### Estado actual (código en `mvp/Whatsapp-bot/`)
|
||||
|
||||
El bot de Luisa es un servicio NestJS independiente que:
|
||||
|
||||
1. **Se conecta a WhatsApp** usando la librería Baileys (WebSocket no oficial)
|
||||
2. **Orquesta un pipeline Claude de 4 capas:**
|
||||
- Capa 1 — Clasificador (Haiku): extrae intención y valor del mensaje
|
||||
- Capa 2 — Validador: valida contra valores permitidos
|
||||
- Capa 3 — Generador (Sonnet): produce el borrador de respuesta
|
||||
- Capa 4 — Reglas (Haiku): corrige tono e identidad
|
||||
3. **Mantiene una máquina de estados** de 7 pasos para cualificar al lead:
|
||||
`nuevo → apertura → espacio → tamano → estilo → urgencia → presupuesto → fin`
|
||||
4. **Tiene un scheduler** que cada 5 minutos busca leads "nuevos" en BD y les envía el mensaje de apertura
|
||||
5. **Soporta multimedia:** transcripción de audio (Gemini), análisis de imágenes (Claude Vision)
|
||||
|
||||
### Problema actual
|
||||
|
||||
El bot **escribe directamente a Postgres** usando TypeORM con sus propias entidades (`Lead` y `Conversacion`), en lugar de usar los endpoints HTTP de la app. Esto causa:
|
||||
|
||||
- Incompatibilidad de IDs: el bot usa `id` numérico autoincremental, la app usa UUID
|
||||
- Tablas duplicadas: el bot tiene su propia tabla `conversacion`, la app tiene `conversacion_whatsapp`
|
||||
- Enums desalineados: el bot usa `urgente/medio_plazo/frio`, la app usa `alta/media/baja`
|
||||
- `synchronize: true` en TypeORM puede alterar el schema de la BD real
|
||||
- El scheduler crea leads desde cero, pero según la arquitectura los leads ya existen (creados desde el form web)
|
||||
|
||||
### Lo que DEBE hacer el bot
|
||||
|
||||
1. **No crear leads.** Recibirlos vía `WHATSAPP_START_WEBHOOK_URL` con el `leadId` UUID.
|
||||
2. **No escribir a BD directamente.** Usar los 5 endpoints HTTP (`conversacion`, `perfil`, `calificacion`, `intento`, `ingesta`).
|
||||
3. **No tener scheduler propio.** El arranque lo hace la app vía webhook.
|
||||
4. **Usar los enums correctos** según la app (`alta/media/baja`, `cocina/bano/salon/comedor/integral/otro`, etc.).
|
||||
5. **No mantener estado propio.** El estado de la conversación (`botStep`) se persiste vía `/perfil`.
|
||||
|
||||
### Dónde está el código
|
||||
|
||||
| Elemento | Ruta |
|
||||
| ----------------------- | ---------------------------- |
|
||||
| Código del bot (NestJS) | `mvp/Whatsapp-bot/src/` |
|
||||
| Prompts de Luisa | `mvp/Whatsapp-bot/prompts/` |
|
||||
| Configuración (.env) | `mvp/Whatsapp-bot/.env` |
|
||||
| Documentación del bot | `mvp/Whatsapp-bot/README.md` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Workers: render de imágenes y presupuesto
|
||||
|
||||
### Estado actual
|
||||
|
||||
Los workers **no están implementados**. Existe:
|
||||
|
||||
- La **tabla `worker_jobs`** en la BD (`mvp/b2c/src/db/schema.ts:515-537`) con tipos: `analisis_fotos`, `render`, `presupuesto_ia`
|
||||
- El **webhook `PERFIL_WEBHOOK_URL`** listo para enviar el perfil completo al worker
|
||||
- El **endpoint `ingesta`** listo para recibir los renders de vuelta
|
||||
- **Renders simulados** con imágenes estáticas en `procesarLead()` (usa `/despues.webp`, `/despues-bano.webp`, etc.)
|
||||
|
||||
### Lo que DEBE hacer el worker de renders
|
||||
|
||||
```
|
||||
1. Recibe POST a PERFIL_WEBHOOK_URL con:
|
||||
{ leadId, cliente, reforma, empresa, zonas: [{ zona, notas, fotos: { antes: [...], despues: [] } }] }
|
||||
|
||||
2. Para cada zona:
|
||||
a. Toma las fotos "antes" del cliente
|
||||
b. Genera render "después" usando modelo de IA (Nano Banana 2, Image 2, Stable Diffusion, etc.)
|
||||
c. Convierte el render a data URI base64
|
||||
|
||||
3. Devuelve los renders haciendo POST a /api/leads/:id/ingesta:
|
||||
{ items: [{ tipo: "foto", zona: "cocina", momento: "despues", imagen: "data:image/..." }],
|
||||
finalizar: true }
|
||||
(finalizar:true dispara la construcción del PDF + email + señal WhatsApp)
|
||||
```
|
||||
|
||||
### Stack planeado para renders
|
||||
|
||||
Según la documentación:
|
||||
|
||||
- **Nano Banana 2** o **Image 2** (Google Gemini) — modelos image-to-image
|
||||
- Alternativa: **Replicate SDXL + ControlNet** (~0,02€/imagen)
|
||||
- Alternativa: **DALL-E 3 HD** (~0,08€/imagen)
|
||||
|
||||
### Dónde implementar los workers
|
||||
|
||||
Los workers son servicios externos independientes. Pueden ser:
|
||||
|
||||
- **n8n workflows** (orquestación visual)
|
||||
- **Un worker Node.js/Python** que escucha webhooks y se comunica con APIs de IA
|
||||
- **Cloud Functions** (Vercel, Cloudflare Workers, AWS Lambda)
|
||||
|
||||
No hay código de workers en este repositorio. El repositorio solo define el contrato (webhooks + API).
|
||||
|
||||
---
|
||||
|
||||
## 9. El presupuesto como entregable final
|
||||
|
||||
El entregable final es un **PDF de presupuesto** que incluye:
|
||||
|
||||
1. **Cabecera** con datos del reformista (logo, nombre, CIF, dirección)
|
||||
2. **Datos del cliente** y tipo de reforma
|
||||
3. **Tabla de presupuesto** con partidas calculadas por el motor:
|
||||
- Demolición, impermeabilización, alicatado, fontanería, electricidad, carpintería, mano de obra, extras, licencia
|
||||
4. **Render "después"** generado por IA
|
||||
5. **Galería por zona** con fotos "antes" y "después"
|
||||
6. **Footer legal**
|
||||
|
||||
### El motor de presupuesto (`mvp/b2c/src/budget/`)
|
||||
|
||||
**Es real, no simulado.** Calcula presupuestos basado en:
|
||||
|
||||
- Catálogo de materiales del reformista (precios por calidad: básica/media/premium)
|
||||
- Configuración de precios (factores por zona, mano de obra, extras fijos)
|
||||
- Inputs del lead: tipo de reforma, m², calidad, urgencia, estructural, provincia
|
||||
|
||||
### Flujo de entrega
|
||||
|
||||
```
|
||||
finalizarYEntregar(leadId)
|
||||
├── construirPresupuestoPdf(leadId)
|
||||
│ ├── Carga lead completo + fotos + notas + catálogo + config
|
||||
│ ├── Renderiza PDF con @react-pdf
|
||||
│ └── Devuelve buffer PDF + filename
|
||||
│
|
||||
├── UPDATE lead (pdfUrl)
|
||||
│
|
||||
├── enviarPresupuestoEmail() → SMTP (opcional)
|
||||
│
|
||||
├── notificarFlujoWhatsapp() → WHATSAPP_WEBHOOK_URL
|
||||
│ └── Bot recibe pdfBase64 y lo envía por WhatsApp
|
||||
│
|
||||
└── UPDATE lead (pipelineStage: 'whatsapp_entregado', estado: 'presupuesto_enviado')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Diagrama de flujo completo
|
||||
|
||||
```
|
||||
LANDING PÚBLICA /[slug]
|
||||
│
|
||||
crearLead()
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ Lead CREADO │
|
||||
│ pipelineStage: │
|
||||
│ form_completado │
|
||||
│ estado: nuevo │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
│ ELECCIÓN DE CANAL │
|
||||
│ /solicitud/[id]/ │
|
||||
└──────┬────────┬───────┘
|
||||
│ │
|
||||
┌────────┘ └────────┐
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ FORMULARIO │ │ WHATSAPP │
|
||||
│ + fotos │ │ │
|
||||
└──────┬───────┘ │ iniciar │
|
||||
│ │ Whatsapp() │
|
||||
│ └──────┬───────┘
|
||||
│ guardarDetalles │
|
||||
│ YFotos() │ POST WHATSAPP
|
||||
│ │ START WEBHOOK
|
||||
├── Guarda fotos │
|
||||
├── Guarda notas ▼
|
||||
│ ┌──────────────┐
|
||||
└── procesarLead() │ BOT WHATSAPP │
|
||||
│ │ (Luisa) │
|
||||
│ │ │
|
||||
├── Simula │ Inicia │
|
||||
│ llamada │ conversación │
|
||||
│ │ │
|
||||
├── Render │ Por cada msg: │
|
||||
│ demo │ POST /perfil │
|
||||
│ (img fija)│ POST /conv. │
|
||||
│ │ │
|
||||
├── Presup. │ Cuando listo: │
|
||||
│ REAL │ POST /ingesta │
|
||||
│ │ perfilCompleto│
|
||||
│ │ │
|
||||
└── Estado └──────┬────────┘
|
||||
intermedio │
|
||||
│ │
|
||||
▼ │
|
||||
┌────────────────────┘
|
||||
│ PERFIL_WEBHOOK_URL
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ WORKER RENDER │
|
||||
│ Genera imágenes │
|
||||
│ "después" │
|
||||
└────────┬─────────┘
|
||||
│ POST /api/leads/:id/ingesta
|
||||
│ { items: [{tipo:"foto", momento:"despues",...}],
|
||||
│ finalizar: true }
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ finalizarYEntregar() │
|
||||
│ - Construye PDF │
|
||||
│ - Envía email │
|
||||
│ - WHATSAPP_WEBHOOK_URL │
|
||||
└────────┬─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ BOT WHATSAPP │
|
||||
│ Recibe pdfBase64│
|
||||
│ y lo envía al │
|
||||
│ cliente │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Estado actual vs lo que falta
|
||||
|
||||
| Componente | Estado | Notas |
|
||||
| ---------------------------- | ------------------------------- | -------------------------------------------------- |
|
||||
| Landing pública / formulario | ✅ Implementado | Multi-zona, fotos, notas |
|
||||
| Motor de presupuesto | ✅ Implementado | Real, con catálogo y config |
|
||||
| PDF | ✅ Implementado | Con @react-pdf |
|
||||
| Email | ✅ Implementado | SMTP, best-effort |
|
||||
| Endpoints API del bot | ✅ Implementado y en producción | 5 endpoints en `dv3.com.es` |
|
||||
| Webhooks salientes | ✅ Implementado | 3 webhooks listos |
|
||||
| Autenticación bot | ✅ Implementado | Bearer token con FUNNEL_API_KEY |
|
||||
| Bot WhatsApp (Luisa) | ❌ Por reconectar | Hoy escribe directo a BD, debe usar APIs HTTP |
|
||||
| Workers render | ❌ No implementado | Existe solo tabla y webhook, sin worker real |
|
||||
| Renders IA | ❌ Simulado | Usa imágenes estáticas `/despues.webp` |
|
||||
| Llamada Retell real | ⚠️ Parcial | Código listo, depende de config de vars de entorno |
|
||||
| n8n workflows | ❌ No existe | Mencionado en docs pero sin implementar |
|
||||
|
||||
---
|
||||
|
||||
## 12. Guía de conexión: cómo integrar Luisa + Workers con la app
|
||||
|
||||
### 12.1 Lo que necesita el bot de WhatsApp (Luisa)
|
||||
|
||||
El bot DEBE:
|
||||
|
||||
1. **Recibir leads por webhook** (`WHATSAPP_START_WEBHOOK_URL`), no buscarlos en BD.
|
||||
2. **Usar los endpoints HTTP de la app** en lugar de TypeORM:
|
||||
- `POST /api/leads/:id/conversacion` — para cada turno del chat
|
||||
- `POST /api/leads/:id/perfil` — para actualizar datos extraídos
|
||||
- `POST /api/leads/:id/calificacion` — para calificar al lead
|
||||
- `POST /api/leads/:id/intento` — para registrar intentos
|
||||
- `POST /api/leads/:id/ingesta` — para subir fotos y marcar perfil completo
|
||||
3. **Usar los enums correctos** de la app:
|
||||
- `urgencia`: `alta`, `media`, `baja`
|
||||
- `tipoReforma`: `cocina`, `bano`, `salon`, `comedor`, `integral`, `otro`
|
||||
- `calidadGlobal`: `basica`, `media`, `premium`
|
||||
- `estadoWa`: `sin_enviar`, `enviado`, `entregado`, `leido`, `fallido`
|
||||
- `canalOrigen`: `formulario_web`, `whatsapp`, `llamada`, `referido`, `anuncio`
|
||||
4. **Trabajar con UUIDs** (el `leadId` que recibe del webhook).
|
||||
5. **No tener scheduler interno** — la app controla cuándo arrancar.
|
||||
|
||||
### 12.2 Lo que necesita el worker de renders
|
||||
|
||||
El worker DEBE:
|
||||
|
||||
1. **Escuchar en la URL que configures como `PERFIL_WEBHOOK_URL`**.
|
||||
2. **Recibir el payload** con `leadId`, `cliente`, `reforma`, `zonas` (con fotos "antes").
|
||||
3. **Generar renders** para cada zona usando un modelo image-to-image.
|
||||
4. **Devolver los renders** llamando a `POST /api/leads/:id/ingesta` con:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"tipo": "foto",
|
||||
"zona": "cocina",
|
||||
"momento": "despues",
|
||||
"imagen": "data:image/..."
|
||||
}
|
||||
],
|
||||
"finalizar": true
|
||||
}
|
||||
```
|
||||
5. **Autenticarse** con `Authorization: Bearer <FUNNEL_API_KEY>`.
|
||||
|
||||
### 12.3 Variables de entorno necesarias en la app
|
||||
|
||||
```
|
||||
# Para que el bot pueda escribir en la BD:
|
||||
FUNNEL_API_KEY=<clave-compartida>
|
||||
|
||||
# URLs donde escuchan el bot y el worker:
|
||||
WHATSAPP_START_WEBHOOK_URL=https://url-del-bot/whatsapp-start
|
||||
PERFIL_WEBHOOK_URL=https://url-del-worker/perfil-completo
|
||||
WHATSAPP_WEBHOOK_URL=https://url-del-bot/whatsapp-pdf
|
||||
```
|
||||
|
||||
### 12.4 Secuencia de integración recomendada
|
||||
|
||||
**Paso 1 — Reconectar Luisa a los endpoints HTTP (prioridad alta)**
|
||||
|
||||
- Eliminar TypeORM y las entidades propias del bot
|
||||
- Implementar llamadas HTTP a los 5 endpoints de la app
|
||||
- Ajustar enums y tipos para que coincidan con la app
|
||||
- Eliminar el scheduler interno
|
||||
- Configurar las 3 URLs de webhook en la app
|
||||
|
||||
**Paso 2 — Implementar worker de renders (prioridad media)**
|
||||
|
||||
- Crear servicio que escuche `PERFIL_WEBHOOK_URL`
|
||||
- Elegir modelo de IA para image-to-image
|
||||
- Integrar con el endpoint `ingesta` para devolver resultados
|
||||
|
||||
**Paso 3 — Conectar llamada Retell real (prioridad baja)**
|
||||
|
||||
- Configurar variables de entorno de Retell
|
||||
- El bot de WhatsApp o el formulario pueden complementar la llamada
|
||||
BIN
docs/flujo-usuario/flujo_reformix.webp
Normal file
BIN
docs/flujo-usuario/flujo_reformix.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
334
docs/flujo-usuario/flujo_reformix.xml
Normal file
334
docs/flujo-usuario/flujo_reformix.xml
Normal file
@@ -0,0 +1,334 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net">
|
||||
<diagram name="Página-1" id="DRtktbz_MXrh0E5vmsnP">
|
||||
<mxGraphModel dx="611" dy="239" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-1" parent="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=20;fontStyle=1;" value="Reformix — Flujo del sistema" vertex="1">
|
||||
<mxGeometry height="40" width="500" x="600" y="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-2" parent="1" style="ellipse;whiteSpace=wrap;html=1;fontSize=13;fontStyle=1;" value="Cliente" vertex="1">
|
||||
<mxGeometry height="50" width="120" x="800" y="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-4" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Formulario web" vertex="1">
|
||||
<mxGeometry height="40" width="150" x="540" y="190" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-5" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="WhatsApp directo" vertex="1">
|
||||
<mxGeometry height="40" width="150" x="785" y="157" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-6" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Llamada" vertex="1">
|
||||
<mxGeometry height="40" width="150" x="1030" y="190" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-7" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-2" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-4">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-8" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-2" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-5">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-9" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-2" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-6">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-10" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Crear lead en DB
(canal_origen registrado)" vertex="1">
|
||||
<mxGeometry height="50" width="250" x="735" y="280" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-11" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-4" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-10">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-12" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-5" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-10">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-13" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-6" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-10">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-14" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Registrar intento
(intentos_contacto)" vertex="1">
|
||||
<mxGeometry height="50" width="250" x="735" y="370" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-15" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-10" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-14">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-16" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="Formulario" vertex="1">
|
||||
<mxGeometry height="20" width="150" x="400" y="450" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-17" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Cliente sube fotos
(lead_fotos · antes)" vertex="1">
|
||||
<mxGeometry height="50" width="170" x="390" y="480" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-18" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-14" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-17">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="735" y="395" />
|
||||
<mxPoint x="475" y="395" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-19" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes
analiza + clasifica fotos
(lead_fotos · render_url)" vertex="1">
|
||||
<mxGeometry height="60" width="170" x="390" y="570" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-20" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-17" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-19">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-21" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="→ Calificación" vertex="1">
|
||||
<mxGeometry height="40" width="120" x="415" y="665" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-22" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-19" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-21">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-23" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="WhatsApp — Luisa" vertex="1">
|
||||
<mxGeometry height="20" width="200" x="710" y="450" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-24" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Scheduler 5 min
→ Bot Luisa
(estado_wa = nuevo)" vertex="1">
|
||||
<mxGeometry height="60" width="180" x="720" y="480" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-25" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-14" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-24">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-26" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;" value="Luisa cualifica (7 estados)
apertura→espacio→tamaño
→estilo→urgencia→presupuesto
[conversacion_whatsapp]" vertex="1">
|
||||
<mxGeometry height="70" width="200" x="710" y="575" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-27" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-24" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-26">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-28" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Viable?
(≥ 5000€)" vertex="1">
|
||||
<mxGeometry height="70" width="140" x="740" y="675" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-29" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-26" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-28">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-30" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#fff2cc;strokeColor=#d6b656;" value="Lead no_viable
(descartado)" vertex="1">
|
||||
<mxGeometry height="50" width="130" x="580" y="690" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-31" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-28" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-30" value="No">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-32" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Pide fotos por WA
(leads · fotos_solicitadas_at)" vertex="1">
|
||||
<mxGeometry height="50" width="180" x="720" y="780" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-33" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-28" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-32" value="Sí">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-34" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Fotos
recibidas?" vertex="1">
|
||||
<mxGeometry height="70" width="140" x="740" y="860" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-35" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-32" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-34">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-36" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="Recordatorio automático
↻ vuelve a preguntar" vertex="1">
|
||||
<mxGeometry height="50" width="140" x="530" y="920" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-37" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-34" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-36" value="No">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-38" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-36" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-34">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="650" y="935" />
|
||||
<mxPoint x="650" y="895" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-39" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes
(lead_fotos · antes)" vertex="1">
|
||||
<mxGeometry height="50" width="180" x="720" y="965" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-40" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-34" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-39" value="Sí">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-41" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="→ Calificación" vertex="1">
|
||||
<mxGeometry height="40" width="150" x="735" y="1045" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-42" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-39" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-41">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-43" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="Llamada" vertex="1">
|
||||
<mxGeometry height="20" width="150" x="1030" y="450" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-44" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;dashed=1;" value="Bot de llamada
(compañero — pendiente)" vertex="1">
|
||||
<mxGeometry height="50" width="170" x="1030" y="480" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-45" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-14" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-44">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="985" y="395" />
|
||||
<mxPoint x="1115" y="395" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-46" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Llamada
completada?" vertex="1">
|
||||
<mxGeometry height="70" width="140" x="1045" y="560" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-47" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-44" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-46">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-48" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="WA de continuación
retoma desde estado_wa" vertex="1">
|
||||
<mxGeometry height="50" width="170" x="1210" y="573" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-49" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-46" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-48" value="No / cortada">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-50" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-48" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-24">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1295" y="440" />
|
||||
<mxPoint x="870" y="440" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-51" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Contactado?" vertex="1">
|
||||
<mxGeometry height="70" width="140" x="1045" y="660" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-52" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-46" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-51" value="Sí">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-53" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-51" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-24" value="No">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1045" y="695" />
|
||||
<mxPoint x="960" y="695" />
|
||||
<mxPoint x="960" y="510" />
|
||||
<mxPoint x="860" y="510" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-54" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Enviar WA pidiendo fotos
(leads · fotos_solicitadas_at)" vertex="1">
|
||||
<mxGeometry height="50" width="170" x="1030" y="765" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-55" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-51" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-54" value="Sí">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-56" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Fotos
recibidas?" vertex="1">
|
||||
<mxGeometry height="70" width="140" x="1045" y="845" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-57" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-54" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-56">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-58" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="Recordatorio automático
↻ vuelve a preguntar" vertex="1">
|
||||
<mxGeometry height="50" width="150" x="1250" y="895" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-59" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-56" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-58" value="No">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-60" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-58" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-56">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1360" y="885" />
|
||||
<mxPoint x="1360" y="880" />
|
||||
<mxPoint x="1185" y="880" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-61" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes
(lead_fotos · antes)" vertex="1">
|
||||
<mxGeometry height="50" width="170" x="1030" y="945" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-62" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-56" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-61" value="Sí">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-63" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="→ Calificación" vertex="1">
|
||||
<mxGeometry height="40" width="150" x="1045" y="1025" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-64" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-61" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-63">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-65" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="Todas las ramas convergen aquí" vertex="1">
|
||||
<mxGeometry height="20" width="400" x="660" y="1110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-66" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;strokeWidth=2;" value="Calificación del lead
score + nivel frío/tibio/caliente
(lead_calificacion)" vertex="1">
|
||||
<mxGeometry height="60" width="300" x="710" y="1140" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-67" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-21" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-66">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="475" y="1170" />
|
||||
<mxPoint x="710" y="1170" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-68" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-41" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-66">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="810" y="1170" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-69" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-63" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-66">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1120" y="1170" />
|
||||
<mxPoint x="1010" y="1170" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-70" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="CRM — Agente revisa lead
(leads · estado = contactado)" vertex="1">
|
||||
<mxGeometry height="50" width="300" x="710" y="1240" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-71" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-66" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-70">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-72" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Visita agendada
(visitas)" vertex="1">
|
||||
<mxGeometry height="50" width="300" x="710" y="1330" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-73" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-70" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-72">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-74" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes
genera render antes/después
(lead_fotos · render_url)" vertex="1">
|
||||
<mxGeometry height="60" width="300" x="710" y="1420" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-75" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-72" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-74">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-76" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Generación presupuesto
render + PDF
(leads · pdf_url)" vertex="1">
|
||||
<mxGeometry height="60" width="300" x="710" y="1520" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-77" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-74" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-76">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-78" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Envío al cliente
WhatsApp / email" vertex="1">
|
||||
<mxGeometry height="50" width="300" x="710" y="1620" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-79" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-76" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-78">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-80" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Acepta?" vertex="1">
|
||||
<mxGeometry height="70" width="140" x="790" y="1700" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-81" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-78" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-80">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-82" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="Lead GANADO
(leads · estado = ganado)" vertex="1">
|
||||
<mxGeometry height="50" width="220" x="680" y="1810" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-83" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-80" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-82" value="Sí">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-84" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#f8cecc;strokeColor=#b85450;" value="Lead PERDIDO
(leads · estado = perdido)" vertex="1">
|
||||
<mxGeometry height="50" width="220" x="970" y="1718" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-85" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-80" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-84" value="No">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-86" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Solicitar testimonio
(testimonios)" vertex="1">
|
||||
<mxGeometry height="50" width="220" x="680" y="1900" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-87" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-82" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-86">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-88" parent="1" style="text;html=1;fontSize=13;fontStyle=1;" value="Leyenda" vertex="1">
|
||||
<mxGeometry height="20" width="100" x="1300" y="1140" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-89" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;" value="Flujo principal" vertex="1">
|
||||
<mxGeometry height="30" width="160" x="1300" y="1170" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-90" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="Bot externo / pendiente" vertex="1">
|
||||
<mxGeometry height="30" width="160" x="1300" y="1210" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-91" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes" vertex="1">
|
||||
<mxGeometry height="30" width="160" x="1300" y="1250" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-92" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#d5e8d4;strokeColor=#82b366;" value="Lead ganado / éxito" vertex="1">
|
||||
<mxGeometry height="30" width="160" x="1300" y="1290" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="nWkRbZFlf69J1CHv_Vaw-93" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#f8cecc;strokeColor=#b85450;" value="Lead perdido / descartado" vertex="1">
|
||||
<mxGeometry height="30" width="160" x="1300" y="1330" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -4,5 +4,6 @@ MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5
|
||||
MODEL_REGLAS=anthropic/claude-haiku-4-5
|
||||
MODEL_TRANSCRIPCION=google/gemini-2.5-flash
|
||||
MODEL=
|
||||
DATABASE_URL=
|
||||
ALLOWED_NUMBER=
|
||||
API_BASE_URL=http://localhost:3000
|
||||
FUNNEL_API_KEY=
|
||||
|
||||
3542
mvp/Whatsapp-bot/package-lock.json
generated
3542
mvp/Whatsapp-bot/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "reformix-luisa-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Agente WhatsApp Luisa para Reformix – cualificacion de leads de reforma",
|
||||
"description": "Agente WhatsApp Luisa para Reformix – cualificacion de leads de reforma via API HTTP",
|
||||
"author": "Reformix",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
@@ -15,28 +15,20 @@
|
||||
"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"
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"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": "^7.0.0-rc10",
|
||||
"axios": "^1.7.0",
|
||||
"baileys-antiban": "^3.9.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"
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
@@ -56,16 +48,10 @@
|
||||
"typescript": "^5.5.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
|
||||
137
mvp/Whatsapp-bot/src/api/api-client.service.ts
Normal file
137
mvp/Whatsapp-bot/src/api/api-client.service.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface LeadState {
|
||||
id: string;
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
botStep: string;
|
||||
estadoWa: string;
|
||||
espacio: string;
|
||||
rangoM2: string;
|
||||
estilo: string;
|
||||
presupuestoDeclarado: string;
|
||||
viable: boolean | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiClient {
|
||||
private readonly logger = new Logger(ApiClient.name);
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
|
||||
this.apiKey = process.env.FUNNEL_API_KEY || '';
|
||||
}
|
||||
|
||||
private get headers() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async getLead(leadId: string): Promise<LeadState | null> {
|
||||
try {
|
||||
const { data } = await axios.get(`${this.baseUrl}/api/leads/${leadId}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) return null;
|
||||
this.logger.error(`Error fetching lead ${leadId}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async guardarConversacion(
|
||||
leadId: string,
|
||||
rol: 'user' | 'assistant' | 'system',
|
||||
mensaje: string,
|
||||
options?: { estadoWa?: string; botStep?: string; mediaType?: string; mediaUrl?: string; transcripcionAudio?: string },
|
||||
): Promise<boolean> {
|
||||
return this.post(`/api/leads/${leadId}/conversacion`, {
|
||||
rol,
|
||||
mensaje,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async actualizarPerfil(
|
||||
leadId: string,
|
||||
datos: Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
return this.post(`/api/leads/${leadId}/perfil`, datos);
|
||||
}
|
||||
|
||||
async obtenerHistorial(leadId: string): Promise<Array<{ role: string; content: string }>> {
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`${this.baseUrl}/api/leads/${leadId}/conversacion`,
|
||||
{ headers: this.headers },
|
||||
);
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((m: any) => ({ role: m.rol || m.role, content: m.mensaje || m.content }));
|
||||
}
|
||||
return [];
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) return [];
|
||||
this.logger.error(`Error fetching historial for ${leadId}: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async calificarLead(
|
||||
leadId: string,
|
||||
score: number,
|
||||
nivel: 'A' | 'B' | 'C' | 'D',
|
||||
criterios?: Record<string, unknown>,
|
||||
notasAgente?: string,
|
||||
): Promise<boolean> {
|
||||
return this.post(`/api/leads/${leadId}/calificacion`, {
|
||||
score,
|
||||
nivel,
|
||||
criterios,
|
||||
notasAgente,
|
||||
});
|
||||
}
|
||||
|
||||
async registrarIntento(
|
||||
leadId: string,
|
||||
canal: string,
|
||||
numeroIntento: number,
|
||||
resultado?: string,
|
||||
completado?: boolean,
|
||||
): Promise<boolean> {
|
||||
return this.post(`/api/leads/${leadId}/intento`, {
|
||||
canal,
|
||||
numeroIntento,
|
||||
resultado,
|
||||
completado,
|
||||
});
|
||||
}
|
||||
|
||||
async enviarIngesta(
|
||||
leadId: string,
|
||||
items: Array<Record<string, unknown>>,
|
||||
flags?: { perfilCompleto?: boolean; finalizar?: boolean },
|
||||
): Promise<boolean> {
|
||||
return this.post(`/api/leads/${leadId}/ingesta`, {
|
||||
items,
|
||||
...flags,
|
||||
});
|
||||
}
|
||||
|
||||
private async post(path: string, body: unknown): Promise<boolean> {
|
||||
try {
|
||||
const { status } = await axios.post(`${this.baseUrl}${path}`, body, {
|
||||
headers: this.headers,
|
||||
});
|
||||
return status === 200;
|
||||
} catch (err: any) {
|
||||
this.logger.error(`POST ${path} error: ${err.response?.status} ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
mvp/Whatsapp-bot/src/api/api.module.ts
Normal file
9
mvp/Whatsapp-bot/src/api/api.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { ApiClient } from './api-client.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ApiClient],
|
||||
exports: [ApiClient],
|
||||
})
|
||||
export class ApiModule {}
|
||||
@@ -1,34 +1,22 @@
|
||||
import 'dotenv/config';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ApiModule } from './api/api.module';
|
||||
import { LeadsModule } from './leads/leads.module';
|
||||
import { ConversacionModule } from './conversacion/conversacion.module';
|
||||
import { WhatsappModule } from './whatsapp/whatsapp.module';
|
||||
import { ClaudeModule } from './claude/claude.module';
|
||||
import { MediaModule } from './media/media.module';
|
||||
import { SchedulerModule } from './scheduler/scheduler.module';
|
||||
import { Lead } from './leads/lead.entity';
|
||||
import { Conversacion } from './conversacion/conversacion.entity';
|
||||
import { WebhookModule } from './webhook/webhook.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
url: process.env.DATABASE_URL,
|
||||
entities: [Lead, Conversacion],
|
||||
synchronize: true, // En produccion usar migrations en lugar de synchronize
|
||||
ssl: process.env.DATABASE_URL?.includes('sslmode=require')
|
||||
? { rejectUnauthorized: false }
|
||||
: false,
|
||||
}),
|
||||
ApiModule,
|
||||
LeadsModule,
|
||||
ConversacionModule,
|
||||
WhatsappModule,
|
||||
ClaudeModule,
|
||||
MediaModule,
|
||||
SchedulerModule,
|
||||
WebhookModule,
|
||||
],
|
||||
})
|
||||
export class AppModule { }
|
||||
export class AppModule {}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import axios from 'axios';
|
||||
import { Lead } from '../leads/lead.entity';
|
||||
import { LeadsService } from '../leads/leads.service';
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT =
|
||||
@@ -34,9 +33,23 @@ export interface ValidacionResultado {
|
||||
viable?: boolean;
|
||||
}
|
||||
|
||||
export interface LeadBasico {
|
||||
id: string;
|
||||
telefono: string;
|
||||
nombre: string;
|
||||
estado_actual: string;
|
||||
espacio: string | null;
|
||||
rango_m2: string | null;
|
||||
estilo: string | null;
|
||||
urgencia: string | null;
|
||||
presupuesto_declarado: string | null;
|
||||
viable: boolean | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface ClaudeResponse {
|
||||
respuesta: string;
|
||||
entidad?: Partial<Lead>;
|
||||
entidad?: Partial<LeadBasico>;
|
||||
viable?: boolean;
|
||||
nuevoEstado?: string;
|
||||
}
|
||||
@@ -47,73 +60,39 @@ export class ClaudeService implements OnModuleInit {
|
||||
private readonly promptsDir = path.join(process.cwd(), 'prompts');
|
||||
private systemPromptCache = '';
|
||||
private reglasPromptCache = '';
|
||||
private readonly reintentosPorLead = new Map<
|
||||
string,
|
||||
{ estado: string; count: number }
|
||||
>();
|
||||
private readonly reintentosPorLead = new Map<string, { estado: string; count: number }>();
|
||||
|
||||
constructor(private readonly leadsService: LeadsService) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.systemPromptCache = this.cargarPrompts([
|
||||
'luisa_core.md',
|
||||
'luisa_flujo.md',
|
||||
'luisa_casos.md',
|
||||
]);
|
||||
this.systemPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_flujo.md', 'luisa_casos.md']);
|
||||
this.reglasPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_casos.md']);
|
||||
this.logger.log(
|
||||
`Prompts cargados: system=${this.systemPromptCache.length} chars, reglas=${this.reglasPromptCache.length} chars`,
|
||||
);
|
||||
this.logger.log(`Prompts cargados: system=${this.systemPromptCache.length} chars, reglas=${this.reglasPromptCache.length} chars`);
|
||||
}
|
||||
|
||||
private cargarPrompts(archivos: string[]): string {
|
||||
const partes: string[] = [];
|
||||
|
||||
for (const archivo of archivos) {
|
||||
const rutaCompleta = path.join(this.promptsDir, archivo);
|
||||
try {
|
||||
if (!fs.existsSync(rutaCompleta)) {
|
||||
this.logger.warn(`Prompt no encontrado: ${archivo}`);
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(rutaCompleta)) { this.logger.warn(`Prompt no encontrado: ${archivo}`); continue; }
|
||||
const contenido = fs.readFileSync(rutaCompleta, 'utf-8');
|
||||
if (contenido.trim()) {
|
||||
partes.push(`\n\n## ${archivo}\n${contenido}`);
|
||||
if (contenido.trim()) partes.push(`\n\n## ${archivo}\n${contenido}`);
|
||||
} catch { this.logger.warn(`No se pudo leer el prompt: ${archivo}`); }
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn(`No se pudo leer el prompt: ${archivo}`);
|
||||
}
|
||||
}
|
||||
|
||||
const concatenado = partes.join('\n').trim();
|
||||
return concatenado || DEFAULT_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
private leerPromptsSistema(): string {
|
||||
return this.systemPromptCache || DEFAULT_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
private leerPromptsReglas(): string {
|
||||
return this.reglasPromptCache || DEFAULT_SYSTEM_PROMPT;
|
||||
return partes.join('\n').trim() || DEFAULT_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
private getModelo(clave: 'clasificador' | 'generador' | 'reglas'): string {
|
||||
const defaults = {
|
||||
clasificador: 'anthropic/claude-haiku-4-5',
|
||||
generador: 'anthropic/claude-sonnet-4-5',
|
||||
reglas: 'anthropic/claude-haiku-4-5',
|
||||
};
|
||||
|
||||
const envMap = {
|
||||
const envMap: Record<string, string | undefined> = {
|
||||
clasificador: process.env.MODEL_CLASIFICADOR,
|
||||
generador: process.env.MODEL_GENERADOR || process.env.MODEL,
|
||||
reglas: process.env.MODEL_REGLAS || process.env.MODEL_CLASIFICADOR,
|
||||
};
|
||||
|
||||
return envMap[clave] || defaults[clave];
|
||||
return envMap[clave] || (clave === 'generador' ? 'anthropic/claude-sonnet-4-5' : 'anthropic/claude-haiku-4-5');
|
||||
}
|
||||
|
||||
private serializarLead(lead: Lead): string {
|
||||
private serializarLead(lead: LeadBasico): string {
|
||||
return [
|
||||
`- ID: ${lead.id}`,
|
||||
`- Telefono: ${lead.telefono}`,
|
||||
@@ -129,10 +108,6 @@ export class ClaudeService implements OnModuleInit {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRouter requiere system dentro de messages[] para modelos OpenAI.
|
||||
* El campo system en la raiz del payload no siempre se aplica.
|
||||
*/
|
||||
private async llamarOpenRouter(
|
||||
model: string,
|
||||
system: string,
|
||||
@@ -140,95 +115,47 @@ export class ClaudeService implements OnModuleInit {
|
||||
options: { temperature?: number; jsonMode?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const { temperature = 0.7, jsonMode = false } = options;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
messages: [{ role: 'system', content: system }, ...messages],
|
||||
max_tokens: 1024,
|
||||
temperature,
|
||||
};
|
||||
if (jsonMode) payload.response_format = { type: 'json_object' };
|
||||
|
||||
if (jsonMode) {
|
||||
payload.response_format = { type: 'json_object' };
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
'https://openrouter.ai/api/v1/chat/completions',
|
||||
payload,
|
||||
{
|
||||
const response = await axios.post('https://openrouter.ai/api/v1/chat/completions', payload, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Luisa Bot',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const contenido = response.data.choices?.[0]?.message?.content || '';
|
||||
const modeloUsado = response.data.model || model;
|
||||
|
||||
if (!contenido.trim()) {
|
||||
this.logger.warn(
|
||||
`OpenRouter devolvio contenido vacio (modelo=${modeloUsado})`,
|
||||
);
|
||||
}
|
||||
|
||||
return contenido;
|
||||
});
|
||||
return response.data.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
private parsearJson<T>(texto: string): T | null {
|
||||
const limpio = texto
|
||||
.replace(/```json\s*/gi, '')
|
||||
.replace(/```\s*/g, '')
|
||||
.trim();
|
||||
|
||||
const limpio = texto.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
|
||||
const inicio = limpio.indexOf('{');
|
||||
const fin = limpio.lastIndexOf('}');
|
||||
if (inicio === -1 || fin === -1) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(limpio.slice(inicio, fin + 1)) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try { return JSON.parse(limpio.slice(inicio, fin + 1)) as T; } catch { return null; }
|
||||
}
|
||||
|
||||
private normalizarClasificacion(
|
||||
raw: Partial<ClasificacionResultado>,
|
||||
): ClasificacionResultado | null {
|
||||
const intenciones = [
|
||||
'respuesta',
|
||||
'desvio',
|
||||
'despedida',
|
||||
'insulto',
|
||||
'pregunta',
|
||||
] as const;
|
||||
|
||||
private normalizarClasificacion(raw: Partial<ClasificacionResultado>): ClasificacionResultado | null {
|
||||
const intenciones = ['respuesta', 'desvio', 'despedida', 'insulto', 'pregunta'] as const;
|
||||
if (!raw || typeof raw.responde_pregunta !== 'boolean') return null;
|
||||
|
||||
const intencion = intenciones.includes(raw.intencion as typeof intenciones[number])
|
||||
? (raw.intencion as ClasificacionResultado['intencion'])
|
||||
: 'respuesta';
|
||||
|
||||
? (raw.intencion as ClasificacionResultado['intencion']) : 'respuesta';
|
||||
return {
|
||||
responde_pregunta: raw.responde_pregunta,
|
||||
valor_extraido:
|
||||
raw.valor_extraido === null || raw.valor_extraido === undefined
|
||||
? null
|
||||
: String(raw.valor_extraido),
|
||||
valor_extraido: raw.valor_extraido === null || raw.valor_extraido === undefined ? null : String(raw.valor_extraido),
|
||||
es_desvio: Boolean(raw.es_desvio),
|
||||
intencion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Capa 1 — Clasificador (Haiku): extrae intencion y valor del mensaje.
|
||||
*/
|
||||
private async clasificar(
|
||||
mensaje: string,
|
||||
estadoActual: string,
|
||||
): Promise<ClasificacionResultado> {
|
||||
private async clasificar(mensaje: string, estadoActual: string): Promise<ClasificacionResultado> {
|
||||
const valoresPermitidos = this.leadsService.getValoresPermitidos(estadoActual);
|
||||
const system = `Eres un clasificador de mensajes para un bot de cualificacion de leads de reformas.
|
||||
Responde UNICAMENTE con un objeto JSON valido. Sin markdown, sin texto antes ni despues.
|
||||
@@ -247,132 +174,63 @@ Formato exacto:
|
||||
Valores validos de intencion: respuesta, desvio, despedida, insulto, pregunta
|
||||
|
||||
Reglas para valor_extraido:
|
||||
- espacio: cocina, bano, salon, integral, otro
|
||||
- espacio: cocina, bano, salon, comedor, integral, otro
|
||||
- tamano: menos10, 10a20, 20a40, mas40
|
||||
- estilo: funcional, cuidado, exclusivo
|
||||
- urgencia: urgente, medio_plazo, frio
|
||||
- urgencia: alta, media, baja
|
||||
- presupuesto: numero o rango en euros tal como lo dijo el usuario
|
||||
- apertura: null si solo confirma disponibilidad; extrae nombre si lo menciona
|
||||
- Si el usuario pregunta algo (nombre, precios, etc.) usa intencion "pregunta" y responde_pregunta false
|
||||
- Saludos casuales sin confirmar disponibilidad: intencion "desvio", es_desvio true
|
||||
- Usuarios de Madrid/Espana: interpreta coloquialismos, jerga y dialecto peninsular (vale, tio, mola, guay, etc.) como respuesta valida si aportan el dato del estado
|
||||
- Extrae el valor semantico aunque venga en lenguaje coloquial ("pa la cocina" -> espacio cocina, "unos 15 mil" -> presupuesto)`;
|
||||
|
||||
const intentos = [
|
||||
{ jsonMode: true, temperature: 0.1 },
|
||||
{ jsonMode: true, temperature: 0 },
|
||||
];
|
||||
- Extrae el valor semantico aunque venga en lenguaje coloquial`;
|
||||
|
||||
const intentos = [{ jsonMode: true, temperature: 0.1 }, { jsonMode: true, temperature: 0 }];
|
||||
for (const opts of intentos) {
|
||||
const contenido = await this.llamarOpenRouter(
|
||||
this.getModelo('clasificador'),
|
||||
system,
|
||||
[{ role: 'user', content: mensaje }],
|
||||
opts,
|
||||
);
|
||||
|
||||
const parsed = this.normalizarClasificacion(
|
||||
this.parsearJson<Partial<ClasificacionResultado>>(contenido) ?? {},
|
||||
);
|
||||
|
||||
const contenido = await this.llamarOpenRouter(this.getModelo('clasificador'), system, [{ role: 'user', content: mensaje }], opts);
|
||||
const parsed = this.normalizarClasificacion(this.parsearJson<Partial<ClasificacionResultado>>(contenido) ?? {});
|
||||
if (parsed) return parsed;
|
||||
|
||||
this.logger.warn(
|
||||
`Clasificador JSON invalido (intento, modelo=${this.getModelo('clasificador')}): ${contenido.slice(0, 200)}`,
|
||||
);
|
||||
this.logger.warn(`Clasificador JSON invalido (intento): ${contenido.slice(0, 200)}`);
|
||||
}
|
||||
return { responde_pregunta: false, valor_extraido: null, es_desvio: true, intencion: 'desvio' };
|
||||
}
|
||||
|
||||
this.logger.warn('Clasificador agotado reintentos, usando fallback conservador');
|
||||
return {
|
||||
responde_pregunta: false,
|
||||
valor_extraido: null,
|
||||
es_desvio: true,
|
||||
intencion: 'desvio',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Capa 2 — Validador en codigo: valida valor_extraido contra valores permitidos.
|
||||
*/
|
||||
private validar(
|
||||
clasificacion: ClasificacionResultado,
|
||||
estadoActual: string,
|
||||
): ValidacionResultado {
|
||||
private validar(clasificacion: ClasificacionResultado, estadoActual: string): ValidacionResultado {
|
||||
const estado = this.leadsService.normalizarEstadoFlujo(estadoActual);
|
||||
|
||||
if (
|
||||
clasificacion.es_desvio ||
|
||||
clasificacion.intencion === 'desvio' ||
|
||||
clasificacion.intencion === 'pregunta' ||
|
||||
clasificacion.intencion === 'insulto' ||
|
||||
clasificacion.intencion === 'despedida'
|
||||
) {
|
||||
if (clasificacion.es_desvio || clasificacion.intencion === 'desvio' || clasificacion.intencion === 'pregunta' ||
|
||||
clasificacion.intencion === 'insulto' || clasificacion.intencion === 'despedida') {
|
||||
return { valido: false, valorNormalizado: null };
|
||||
}
|
||||
|
||||
if (estado === 'nuevo') {
|
||||
return { valido: false, valorNormalizado: null };
|
||||
}
|
||||
|
||||
if (estado === 'nuevo') return { valido: false, valorNormalizado: null };
|
||||
if (estado === 'apertura') {
|
||||
const valido =
|
||||
clasificacion.responde_pregunta &&
|
||||
clasificacion.intencion === 'respuesta' &&
|
||||
!clasificacion.es_desvio;
|
||||
return { valido, valorNormalizado: clasificacion.valor_extraido };
|
||||
return { valido: clasificacion.responde_pregunta && clasificacion.intencion === 'respuesta' && !clasificacion.es_desvio, valorNormalizado: clasificacion.valor_extraido };
|
||||
}
|
||||
|
||||
if (estado === 'presupuesto') {
|
||||
const valor = clasificacion.valor_extraido?.trim();
|
||||
if (!valor || !this.leadsService.esPresupuestoValido(valor)) {
|
||||
return { valido: false, valorNormalizado: null };
|
||||
if (!valor || !this.leadsService.esPresupuestoValido(valor)) return { valido: false, valorNormalizado: null };
|
||||
return { valido: true, valorNormalizado: valor, viable: this.leadsService.evaluarViabilidad(valor) };
|
||||
}
|
||||
const viable = this.leadsService.evaluarViabilidad(valor);
|
||||
return { valido: true, valorNormalizado: valor, viable };
|
||||
}
|
||||
|
||||
const valoresPermitidos = this.leadsService.getValoresPermitidos(estado);
|
||||
const valor = this.normalizarTexto(clasificacion.valor_extraido ?? '');
|
||||
|
||||
if (!valor) {
|
||||
return { valido: false, valorNormalizado: null };
|
||||
}
|
||||
|
||||
const coincide = valoresPermitidos.some(
|
||||
(v) => v === valor || valor.includes(v) || v.includes(valor),
|
||||
);
|
||||
|
||||
if (!coincide) {
|
||||
return { valido: false, valorNormalizado: null };
|
||||
}
|
||||
|
||||
const valorNormalizado =
|
||||
valoresPermitidos.find(
|
||||
(v) => v === valor || valor.includes(v) || v.includes(valor),
|
||||
) ?? valor;
|
||||
|
||||
if (!valor) return { valido: false, valorNormalizado: null };
|
||||
const coincide = valoresPermitidos.some((v) => v === valor || valor.includes(v) || v.includes(valor));
|
||||
if (!coincide) return { valido: false, valorNormalizado: null };
|
||||
const valorNormalizado = valoresPermitidos.find((v) => v === valor || valor.includes(v) || v.includes(valor)) ?? valor;
|
||||
return { valido: true, valorNormalizado };
|
||||
}
|
||||
|
||||
private normalizarTexto(valor: string): string {
|
||||
return valor
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '');
|
||||
return valor.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
}
|
||||
|
||||
private claveReintento(leadId: number, estado: string): string {
|
||||
return `${leadId}:${estado}`;
|
||||
}
|
||||
private claveReintento(leadId: string, estado: string): string { return `${leadId}:${estado}`; }
|
||||
|
||||
private obtenerReintentos(leadId: number, estado: string): number {
|
||||
const clave = this.claveReintento(leadId, estado);
|
||||
const entry = this.reintentosPorLead.get(clave);
|
||||
private obtenerReintentos(leadId: string, estado: string): number {
|
||||
const entry = this.reintentosPorLead.get(this.claveReintento(leadId, estado));
|
||||
return entry?.estado === estado ? entry.count : 0;
|
||||
}
|
||||
|
||||
private incrementarReintentos(leadId: number, estado: string): number {
|
||||
private incrementarReintentos(leadId: string, estado: string): number {
|
||||
const clave = this.claveReintento(leadId, estado);
|
||||
const actual = this.obtenerReintentos(leadId, estado);
|
||||
const count = actual + 1;
|
||||
@@ -380,15 +238,12 @@ Reglas para valor_extraido:
|
||||
return count;
|
||||
}
|
||||
|
||||
private resetearReintentos(leadId: number, estado: string): void {
|
||||
private resetearReintentos(leadId: string, estado: string): void {
|
||||
this.reintentosPorLead.delete(this.claveReintento(leadId, estado));
|
||||
}
|
||||
|
||||
/**
|
||||
* Capa 3 — Generador (Sonnet): produce el borrador del mensaje de Luisa.
|
||||
*/
|
||||
private async generar(
|
||||
lead: Lead,
|
||||
lead: LeadBasico,
|
||||
historial: Array<{ role: string; content: string }>,
|
||||
mensajeActual: string,
|
||||
clasificacion: ClasificacionResultado,
|
||||
@@ -398,11 +253,7 @@ Reglas para valor_extraido:
|
||||
siguienteEstado: string | null,
|
||||
forzarApertura = false,
|
||||
): Promise<string> {
|
||||
const systemPrompt = this.leerPromptsSistema();
|
||||
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(
|
||||
lead.estado_actual,
|
||||
);
|
||||
|
||||
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
|
||||
const contextoGeneracion = `
|
||||
## Contexto del lead
|
||||
${this.serializarLead(lead)}
|
||||
@@ -419,11 +270,10 @@ ${this.serializarLead(lead)}
|
||||
|
||||
## Instrucciones de respuesta
|
||||
Eres Luisa de Reformix en Madrid. Sigue el system prompt al pie de la letra.
|
||||
Habla espanol de Espana; suena natural y cercana. Adapta el registro al usuario (coloquial si el, formal si el).
|
||||
Habla espanol de Espana; suena natural y cercana.
|
||||
Devuelve SOLO el texto del mensaje a enviar por WhatsApp.
|
||||
NO incluyas JSON, etiquetas XML ni bloques de datos extraidos.
|
||||
NUNCA digas que eres IA, OpenAI, ChatGPT ni un asistente virtual.
|
||||
Si preguntan tu nombre, di que eres Luisa de Reformix.
|
||||
|
||||
Si forzar mensaje de apertura es true, envia el mensaje de APERTURA del flujo.
|
||||
Si validacion valida es false y reintentos < 2, pide amablemente que aclare su respuesta.
|
||||
@@ -432,32 +282,16 @@ Si es_desvio es true o intencion es pregunta, responde brevemente como Luisa y r
|
||||
Si avanzar estado es true, haz la pregunta correspondiente al siguiente estado.
|
||||
Si el siguiente estado es fin_viable o fin_no_viable, usa el mensaje de cierre correspondiente.`;
|
||||
|
||||
const messages = [
|
||||
...historial,
|
||||
{ role: 'user', content: mensajeActual },
|
||||
];
|
||||
|
||||
const contenido = await this.llamarOpenRouter(
|
||||
this.getModelo('generador'),
|
||||
`${systemPrompt}\n${contextoGeneracion}`,
|
||||
messages,
|
||||
const contenido = await this.llamarOpenRouter(this.getModelo('generador'),
|
||||
`${this.systemPromptCache || DEFAULT_SYSTEM_PROMPT}\n${contextoGeneracion}`,
|
||||
[...historial, { role: 'user', content: mensajeActual }],
|
||||
{ temperature: 0.7 },
|
||||
);
|
||||
|
||||
return contenido.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capa 4 — Reglas (Haiku): corrige el borrador para cumplir identidad y tono de Luisa.
|
||||
*/
|
||||
private async aplicarReglas(
|
||||
borrador: string,
|
||||
lead: Lead,
|
||||
estadoFlujo: string,
|
||||
clasificacion: ClasificacionResultado,
|
||||
): Promise<string> {
|
||||
const reglas = this.leerPromptsReglas();
|
||||
|
||||
private async aplicarReglas(borrador: string, lead: LeadBasico, estadoFlujo: string, clasificacion: ClasificacionResultado): Promise<string> {
|
||||
const reglas = this.reglasPromptCache || DEFAULT_SYSTEM_PROMPT;
|
||||
const system = `${reglas}
|
||||
|
||||
## Tu tarea
|
||||
@@ -471,19 +305,16 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
|
||||
## Reglas de correccion obligatorias
|
||||
- Debe sonar como Luisa de Reformix (Madrid), nunca como un asistente generico
|
||||
- Espanol de Espana, natural; puede usar coloquialismos suaves (vale, mira, oye) si encaja con el tono del usuario
|
||||
- Espanol de Espana, natural; puede usar coloquialismos suaves (vale, mira, oye) si encaja
|
||||
- Maximo 2 lineas, un mensaje por turno, sin emojis, sin guiones largos
|
||||
- NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales
|
||||
- Si preguntan el nombre: "Soy Luisa de Reformix"
|
||||
- Si el borrador viola alguna regla, reescribelo completamente manteniendo la intencion`;
|
||||
|
||||
const contenido = await this.llamarOpenRouter(
|
||||
this.getModelo('reglas'),
|
||||
system,
|
||||
const contenido = await this.llamarOpenRouter(this.getModelo('reglas'), system,
|
||||
[{ role: 'user', content: `Borrador a corregir:\n${borrador}` }],
|
||||
{ temperature: 0.3 },
|
||||
);
|
||||
|
||||
return contenido.trim() || borrador;
|
||||
}
|
||||
|
||||
@@ -491,7 +322,7 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
return FRASES_IA_PROHIBIDAS.some((regex) => regex.test(texto));
|
||||
}
|
||||
|
||||
private mensajeFallback(estadoFlujo: string, lead: Lead): string {
|
||||
private mensajeFallback(estadoFlujo: string, lead: LeadBasico): string {
|
||||
const nombre = lead.nombre ? lead.nombre : '';
|
||||
const fallbacks: Record<string, string> = {
|
||||
nuevo: `Hola${nombre ? ' ' + nombre : ''}, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?`,
|
||||
@@ -502,91 +333,23 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
urgencia: 'Y cuando tienes pensado arrancar, es algo proximo o todavia estas explorando?',
|
||||
presupuesto: 'Ultima pregunta; tienes en mente un presupuesto aproximado para la reforma?',
|
||||
};
|
||||
|
||||
return (
|
||||
fallbacks[estadoFlujo] ??
|
||||
'Soy Luisa de Reformix; sigamos con tu presupuesto de reforma.'
|
||||
);
|
||||
return fallbacks[estadoFlujo] ?? 'Soy Luisa de Reformix; sigamos con tu presupuesto de reforma.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Orquesta las 4 capas: clasificar, validar, generar y aplicar reglas.
|
||||
*/
|
||||
async llamarClaude(
|
||||
lead: Lead,
|
||||
lead: LeadBasico,
|
||||
historial: Array<{ role: string; content: string }>,
|
||||
mensajeActual: string,
|
||||
): Promise<ClaudeResponse> {
|
||||
const esAperturaScheduler =
|
||||
historial.length === 0 && mensajeActual.startsWith('APERTURA:');
|
||||
|
||||
if (esAperturaScheduler) {
|
||||
const borrador = await this.generar(
|
||||
lead,
|
||||
historial,
|
||||
mensajeActual,
|
||||
{
|
||||
responde_pregunta: true,
|
||||
valor_extraido: null,
|
||||
es_desvio: false,
|
||||
intencion: 'respuesta',
|
||||
},
|
||||
{ valido: true, valorNormalizado: null },
|
||||
0,
|
||||
false,
|
||||
'apertura',
|
||||
true,
|
||||
);
|
||||
const respuesta = await this.aplicarReglas(
|
||||
borrador,
|
||||
lead,
|
||||
'apertura',
|
||||
{
|
||||
responde_pregunta: true,
|
||||
valor_extraido: null,
|
||||
es_desvio: false,
|
||||
intencion: 'respuesta',
|
||||
},
|
||||
);
|
||||
return {
|
||||
respuesta: this.contieneFraseProhibida(respuesta)
|
||||
? this.mensajeFallback('apertura', lead)
|
||||
: respuesta,
|
||||
};
|
||||
}
|
||||
|
||||
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(
|
||||
lead.estado_actual,
|
||||
);
|
||||
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
|
||||
|
||||
if (estadoFlujo === 'nuevo') {
|
||||
const clasificacion: ClasificacionResultado = {
|
||||
responde_pregunta: false,
|
||||
valor_extraido: null,
|
||||
es_desvio: false,
|
||||
intencion: 'respuesta',
|
||||
};
|
||||
const borrador = await this.generar(
|
||||
lead,
|
||||
historial,
|
||||
mensajeActual,
|
||||
clasificacion,
|
||||
{ valido: false, valorNormalizado: null },
|
||||
0,
|
||||
false,
|
||||
null,
|
||||
true,
|
||||
);
|
||||
const respuesta = await this.aplicarReglas(
|
||||
borrador,
|
||||
lead,
|
||||
'nuevo',
|
||||
clasificacion,
|
||||
);
|
||||
const clasificacion: ClasificacionResultado = { responde_pregunta: false, valor_extraido: null, es_desvio: false, intencion: 'respuesta' };
|
||||
const borrador = await this.generar(lead, historial, mensajeActual, clasificacion,
|
||||
{ valido: false, valorNormalizado: null }, 0, false, null, true);
|
||||
const respuesta = await this.aplicarReglas(borrador, lead, 'nuevo', clasificacion);
|
||||
return {
|
||||
respuesta: this.contieneFraseProhibida(respuesta)
|
||||
? this.mensajeFallback('nuevo', lead)
|
||||
: respuesta,
|
||||
respuesta: this.contieneFraseProhibida(respuesta) ? this.mensajeFallback('nuevo', lead) : respuesta,
|
||||
nuevoEstado: 'apertura',
|
||||
};
|
||||
}
|
||||
@@ -597,72 +360,38 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||
let reintentos = this.obtenerReintentos(lead.id, estadoFlujo);
|
||||
let avanzarEstado = false;
|
||||
let siguienteEstado: string | null = null;
|
||||
let entidad: Partial<Lead> = {};
|
||||
let entidad: Partial<LeadBasico> = {};
|
||||
let viable: boolean | undefined;
|
||||
|
||||
const puedeAvanzar =
|
||||
validacion.valido &&
|
||||
!clasificacion.es_desvio &&
|
||||
clasificacion.intencion === 'respuesta';
|
||||
const puedeAvanzar = validacion.valido && !clasificacion.es_desvio && clasificacion.intencion === 'respuesta';
|
||||
|
||||
if (puedeAvanzar) {
|
||||
avanzarEstado = true;
|
||||
this.resetearReintentos(lead.id, estadoFlujo);
|
||||
|
||||
if (validacion.valorNormalizado) {
|
||||
const campo = this.leadsService.getCampoParaEstado(estadoFlujo);
|
||||
if (campo) {
|
||||
entidad = { [campo]: validacion.valorNormalizado };
|
||||
} else if (
|
||||
estadoFlujo === 'apertura' &&
|
||||
clasificacion.valor_extraido?.trim()
|
||||
) {
|
||||
entidad = { nombre: clasificacion.valor_extraido.trim() };
|
||||
(entidad as any)[campo] = validacion.valorNormalizado;
|
||||
} else if (estadoFlujo === 'apertura' && clasificacion.valor_extraido?.trim()) {
|
||||
entidad.nombre = clasificacion.valor_extraido.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (estadoFlujo === 'presupuesto') {
|
||||
viable = validacion.viable;
|
||||
siguienteEstado = this.leadsService.getSiguienteEstado(
|
||||
estadoFlujo,
|
||||
viable,
|
||||
);
|
||||
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo, viable);
|
||||
} else {
|
||||
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo);
|
||||
}
|
||||
} else if (
|
||||
!validacion.valido &&
|
||||
clasificacion.responde_pregunta &&
|
||||
!clasificacion.es_desvio
|
||||
) {
|
||||
} else if (!validacion.valido && clasificacion.responde_pregunta && !clasificacion.es_desvio) {
|
||||
reintentos = this.incrementarReintentos(lead.id, estadoFlujo);
|
||||
if (reintentos > 2) {
|
||||
reintentos = 2;
|
||||
}
|
||||
if (reintentos > 2) reintentos = 2;
|
||||
}
|
||||
|
||||
const borrador = await this.generar(
|
||||
lead,
|
||||
historial,
|
||||
mensajeActual,
|
||||
clasificacion,
|
||||
validacion,
|
||||
reintentos,
|
||||
avanzarEstado,
|
||||
siguienteEstado,
|
||||
);
|
||||
|
||||
let respuesta = await this.aplicarReglas(
|
||||
borrador,
|
||||
lead,
|
||||
estadoFlujo,
|
||||
clasificacion,
|
||||
);
|
||||
const borrador = await this.generar(lead, historial, mensajeActual, clasificacion, validacion, reintentos, avanzarEstado, siguienteEstado);
|
||||
let respuesta = await this.aplicarReglas(borrador, lead, estadoFlujo, clasificacion);
|
||||
|
||||
if (this.contieneFraseProhibida(respuesta)) {
|
||||
this.logger.warn(
|
||||
`Respuesta final viola reglas de identidad, usando fallback para estado=${estadoFlujo}`,
|
||||
);
|
||||
this.logger.warn(`Respuesta final viola reglas, usando fallback para estado=${estadoFlujo}`);
|
||||
respuesta = this.mensajeFallback(estadoFlujo, lead);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Lead } from '../leads/lead.entity';
|
||||
|
||||
export type RolMensaje = 'user' | 'assistant' | 'system';
|
||||
|
||||
@Entity('conversacion')
|
||||
export class Conversacion {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
lead_id: number;
|
||||
|
||||
@ManyToOne(() => Lead, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'lead_id' })
|
||||
lead: Lead;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
rol: RolMensaje;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
mensaje: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Conversacion } from './conversacion.entity';
|
||||
import { ConversacionService } from './conversacion.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Conversacion])],
|
||||
providers: [ConversacionService],
|
||||
exports: [ConversacionService],
|
||||
})
|
||||
|
||||
@@ -1,41 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Conversacion, RolMensaje } from './conversacion.entity';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ApiClient } from '../api/api-client.service';
|
||||
|
||||
@Injectable()
|
||||
export class ConversacionService {
|
||||
constructor(
|
||||
@InjectRepository(Conversacion)
|
||||
private readonly convRepo: Repository<Conversacion>,
|
||||
) {}
|
||||
private readonly logger = new Logger(ConversacionService.name);
|
||||
|
||||
constructor(private readonly api: ApiClient) {}
|
||||
|
||||
async guardarMensaje(
|
||||
leadId: number,
|
||||
rol: RolMensaje,
|
||||
leadId: string,
|
||||
rol: 'user' | 'assistant' | 'system',
|
||||
mensaje: string,
|
||||
): Promise<Conversacion> {
|
||||
const entry = this.convRepo.create({ lead_id: leadId, rol, mensaje });
|
||||
return this.convRepo.save(entry);
|
||||
options?: { estadoWa?: string; botStep?: string },
|
||||
): Promise<boolean> {
|
||||
const ok = await this.api.guardarConversacion(leadId, rol, mensaje, options);
|
||||
if (!ok) {
|
||||
this.logger.warn(`No se pudo guardar mensaje ${rol} para lead ${leadId}`);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
async obtenerHistorial(leadId: number): Promise<Conversacion[]> {
|
||||
return this.convRepo.find({
|
||||
where: { lead_id: leadId },
|
||||
order: { created_at: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve el historial en formato OpenAI/Claude messages array.
|
||||
*/
|
||||
async obtenerHistorialComoMessages(
|
||||
leadId: number,
|
||||
): Promise<Array<{ role: string; content: string }>> {
|
||||
const historial = await this.obtenerHistorial(leadId);
|
||||
return historial.map((h) => ({
|
||||
role: h.rol,
|
||||
content: h.mensaje,
|
||||
}));
|
||||
async obtenerHistorialComoMessages(leadId: string): Promise<Array<{ role: string; content: string }>> {
|
||||
return this.api.obtenerHistorial(leadId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export type EstadoLead =
|
||||
| 'nuevo'
|
||||
| 'en_proceso'
|
||||
| 'apertura'
|
||||
| 'espacio'
|
||||
| 'tamano'
|
||||
| 'estilo'
|
||||
| 'urgencia'
|
||||
| 'presupuesto'
|
||||
| 'fin_viable'
|
||||
| 'fin_no_viable'
|
||||
| 'recopilando_datos'
|
||||
| 'completado'
|
||||
| 'no_viable'
|
||||
| 'perdido';
|
||||
|
||||
@Entity('leads')
|
||||
export class Lead {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
nombre: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
telefono: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
email: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
espacio: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
rango_m2: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
estilo: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
urgencia: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
presupuesto_declarado: string;
|
||||
|
||||
@Column({ type: 'boolean', nullable: true })
|
||||
viable: boolean;
|
||||
|
||||
@Column({ type: 'text', default: 'nuevo' })
|
||||
estado_actual: EstadoLead;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
url_presupuesto: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Lead } from './lead.entity';
|
||||
import { LeadsService } from './leads.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Lead])],
|
||||
providers: [LeadsService],
|
||||
exports: [LeadsService],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThan } from 'typeorm';
|
||||
import { Lead, EstadoLead } from './lead.entity';
|
||||
import { ApiClient } from '../api/api-client.service';
|
||||
|
||||
const SECUENCIA_ESTADOS = [
|
||||
'nuevo',
|
||||
@@ -14,200 +12,83 @@ const SECUENCIA_ESTADOS = [
|
||||
] as const;
|
||||
|
||||
const VALORES_POR_ESTADO: Record<string, string[]> = {
|
||||
espacio: ['cocina', 'bano', 'salon', 'integral', 'otro'],
|
||||
espacio: ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'],
|
||||
tamano: ['menos10', '10a20', '20a40', 'mas40'],
|
||||
estilo: ['funcional', 'cuidado', 'exclusivo'],
|
||||
urgencia: ['urgente', 'medio_plazo', 'frio'],
|
||||
urgencia: ['alta', 'media', 'baja'],
|
||||
};
|
||||
|
||||
const CAMPO_POR_ESTADO: Record<string, keyof Lead> = {
|
||||
const CAMPO_POR_ESTADO_NOMBRE: Record<string, string> = {
|
||||
espacio: 'espacio',
|
||||
tamano: 'rango_m2',
|
||||
tamano: 'rangoM2',
|
||||
estilo: 'estilo',
|
||||
urgencia: 'urgencia',
|
||||
presupuesto: 'presupuesto_declarado',
|
||||
presupuesto: 'presupuestoDeclarado',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class LeadsService {
|
||||
private readonly logger = new Logger(LeadsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Lead)
|
||||
private readonly leadRepo: Repository<Lead>,
|
||||
) {}
|
||||
constructor(private readonly api: ApiClient) {}
|
||||
|
||||
/**
|
||||
* Normaliza estados legacy del scheduler/DB al flujo de cualificacion.
|
||||
*/
|
||||
normalizarEstadoFlujo(estado: string): string {
|
||||
if (estado === 'en_proceso' || estado === 'recopilando_datos') {
|
||||
return 'apertura';
|
||||
}
|
||||
if (estado === 'en_proceso' || estado === 'recopilando_datos') return 'apertura';
|
||||
return estado;
|
||||
}
|
||||
|
||||
getSiguienteEstado(estadoActual: string, viable?: boolean): string {
|
||||
const estado = this.normalizarEstadoFlujo(estadoActual);
|
||||
|
||||
if (estado === 'presupuesto') {
|
||||
return viable === false ? 'fin_no_viable' : 'fin_viable';
|
||||
}
|
||||
|
||||
const idx = SECUENCIA_ESTADOS.indexOf(
|
||||
estado as (typeof SECUENCIA_ESTADOS)[number],
|
||||
);
|
||||
if (idx === -1 || idx >= SECUENCIA_ESTADOS.length - 1) {
|
||||
return estado;
|
||||
}
|
||||
if (estado === 'presupuesto') return viable === false ? 'fin_no_viable' : 'fin_viable';
|
||||
const idx = SECUENCIA_ESTADOS.indexOf(estado as typeof SECUENCIA_ESTADOS[number]);
|
||||
if (idx === -1 || idx >= SECUENCIA_ESTADOS.length - 1) return estado;
|
||||
return SECUENCIA_ESTADOS[idx + 1];
|
||||
}
|
||||
|
||||
getValoresPermitidos(estado: string): string[] {
|
||||
const estadoNorm = this.normalizarEstadoFlujo(estado);
|
||||
return VALORES_POR_ESTADO[estadoNorm] ?? [];
|
||||
return VALORES_POR_ESTADO[this.normalizarEstadoFlujo(estado)] ?? [];
|
||||
}
|
||||
|
||||
getCampoParaEstado(estado: string): keyof Lead | null {
|
||||
const estadoNorm = this.normalizarEstadoFlujo(estado);
|
||||
return CAMPO_POR_ESTADO[estadoNorm] ?? null;
|
||||
getCampoParaEstado(estado: string): string | null {
|
||||
return CAMPO_POR_ESTADO_NOMBRE[this.normalizarEstadoFlujo(estado)] ?? null;
|
||||
}
|
||||
|
||||
esPresupuestoValido(valor: string): boolean {
|
||||
const normalizado = valor.trim().toLowerCase();
|
||||
if (!normalizado) return false;
|
||||
return /\d/.test(normalizado);
|
||||
return /\d/.test(valor.trim().toLowerCase());
|
||||
}
|
||||
|
||||
evaluarViabilidad(presupuesto: string): boolean {
|
||||
const numeros = presupuesto.match(/\d[\d.]*/g);
|
||||
if (!numeros?.length) return true;
|
||||
|
||||
const valor = parseInt(numeros[0].replace(/\./g, ''), 10);
|
||||
if (Number.isNaN(valor)) return true;
|
||||
|
||||
return valor >= 5000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca un lead por número de teléfono.
|
||||
* Si no existe, lo crea con estado 'nuevo'.
|
||||
*/
|
||||
async findOrCreate(telefono: string): Promise<Lead> {
|
||||
let lead = await this.leadRepo.findOne({ where: { telefono } });
|
||||
if (!lead) {
|
||||
lead = this.leadRepo.create({ telefono, estado_actual: 'nuevo' });
|
||||
lead = await this.leadRepo.save(lead);
|
||||
this.logger.log(`Lead nuevo creado: telefono=${telefono}, id=${lead.id}`);
|
||||
}
|
||||
return lead;
|
||||
}
|
||||
|
||||
async findByTelefono(telefono: string): Promise<Lead | null> {
|
||||
return this.leadRepo.findOne({ where: { telefono } });
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<Lead | null> {
|
||||
return this.leadRepo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findByEstado(estado: EstadoLead): Promise<Lead[]> {
|
||||
return this.leadRepo.find({ where: { estado_actual: estado } });
|
||||
}
|
||||
|
||||
async updateEstado(lead: Lead, estado: EstadoLead | string): Promise<Lead> {
|
||||
await this.leadRepo.update(lead.id, {
|
||||
estado_actual: estado as EstadoLead,
|
||||
});
|
||||
this.logger.log(`Lead id=${lead.id} estado_actual=${estado}`);
|
||||
return this.leadRepo.findOne({ where: { id: lead.id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza campos del lead según el estado actual del flujo.
|
||||
* Solo actualiza los campos que se pasan en el partial.
|
||||
*/
|
||||
async updateDatos(leadId: number, datos: Partial<Lead>): Promise<Lead> {
|
||||
const campos = Object.keys(datos).filter(
|
||||
(k) => datos[k as keyof Lead] !== undefined,
|
||||
);
|
||||
if (campos.length === 0) {
|
||||
return this.leadRepo.findOne({ where: { id: leadId } });
|
||||
}
|
||||
|
||||
await this.leadRepo.update(leadId, datos);
|
||||
this.logger.log(
|
||||
`Lead id=${leadId} datos guardados: ${JSON.stringify(datos)}`,
|
||||
);
|
||||
return this.leadRepo.findOne({ where: { id: leadId } });
|
||||
}
|
||||
|
||||
async marcarViable(lead: Lead, viable: boolean): Promise<Lead> {
|
||||
const estado = viable ? 'completado' : 'no_viable';
|
||||
await this.leadRepo.update(lead.id, { viable, estado_actual: estado });
|
||||
this.logger.log(`Lead id=${lead.id} viable=${viable}, estado=${estado}`);
|
||||
return this.leadRepo.findOne({ where: { id: lead.id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste datos del lead y cambio de estado en una sola operacion.
|
||||
*/
|
||||
async persistirTurno(
|
||||
leadId: number,
|
||||
datos: Partial<Lead>,
|
||||
leadId: string,
|
||||
datos: Record<string, unknown>,
|
||||
options?: { nuevoEstado?: string; viable?: boolean },
|
||||
): Promise<Lead> {
|
||||
const patch: Partial<Lead> = { ...datos };
|
||||
): Promise<boolean> {
|
||||
const perfil: Record<string, unknown> = { ...datos };
|
||||
|
||||
if (options?.nuevoEstado === 'fin_viable') {
|
||||
patch.viable = true;
|
||||
patch.estado_actual = 'completado';
|
||||
perfil.viable = true;
|
||||
perfil.botStep = 'presupuesto';
|
||||
} else if (options?.nuevoEstado === 'fin_no_viable') {
|
||||
patch.viable = false;
|
||||
patch.estado_actual = 'no_viable';
|
||||
perfil.viable = false;
|
||||
perfil.botStep = 'presupuesto';
|
||||
} else if (options?.nuevoEstado) {
|
||||
patch.estado_actual = options.nuevoEstado as EstadoLead;
|
||||
} else if (options?.viable !== undefined && options?.viable !== null) {
|
||||
patch.viable = options.viable;
|
||||
patch.estado_actual = options.viable ? 'completado' : 'no_viable';
|
||||
perfil.botStep = options.nuevoEstado;
|
||||
} else if (options?.viable !== undefined) {
|
||||
perfil.viable = options.viable;
|
||||
}
|
||||
|
||||
const campos = Object.keys(patch).filter(
|
||||
(k) => patch[k as keyof Lead] !== undefined,
|
||||
);
|
||||
if (campos.length === 0) {
|
||||
return this.leadRepo.findOne({ where: { id: leadId } });
|
||||
}
|
||||
const campos = Object.keys(perfil).filter((k) => perfil[k] !== undefined);
|
||||
if (campos.length === 0) return true;
|
||||
|
||||
await this.leadRepo.update(leadId, patch);
|
||||
this.logger.log(
|
||||
`Lead id=${leadId} persistido: ${JSON.stringify(patch)}`,
|
||||
);
|
||||
return this.leadRepo.findOne({ where: { id: leadId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca como perdido cualquier lead en_proceso sin actividad en más de 48h.
|
||||
*/
|
||||
async marcarLeadsPerdidos(): Promise<void> {
|
||||
const hace48h = new Date(Date.now() - 48 * 60 * 60 * 1000);
|
||||
const leadsSinActividad = await this.leadRepo.find({
|
||||
where: {
|
||||
estado_actual: 'en_proceso',
|
||||
updated_at: LessThan(hace48h),
|
||||
},
|
||||
});
|
||||
|
||||
for (const lead of leadsSinActividad) {
|
||||
lead.estado_actual = 'perdido';
|
||||
await this.leadRepo.save(lead);
|
||||
this.logger.warn(
|
||||
`Lead id=${lead.id} marcado como perdido por inactividad > 48h`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async save(lead: Lead): Promise<Lead> {
|
||||
return this.leadRepo.save(lead);
|
||||
const ok = await this.api.actualizarPerfil(leadId, perfil);
|
||||
this.logger.log(`Lead ${leadId} persistido via API: ${JSON.stringify(perfil)} → ${ok ? 'ok' : 'fallo'}`);
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,268 +1,99 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import axios from "axios";
|
||||
import { EstadoLead } from "../leads/lead.entity";
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private readonly logger = new Logger(MediaService.name);
|
||||
|
||||
private readonly OPENROUTER_URL =
|
||||
"https://openrouter.ai/api/v1/chat/completions";
|
||||
private readonly OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
private get headers() {
|
||||
return {
|
||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://reformix.es",
|
||||
"X-Title": "Reformix Luisa Bot",
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Luisa Bot',
|
||||
};
|
||||
}
|
||||
|
||||
private getModeloTranscripcion(): string {
|
||||
return (
|
||||
process.env.MODEL_TRANSCRIPCION || "google/gemini-2.5-flash"
|
||||
);
|
||||
return process.env.MODEL_TRANSCRIPCION || 'google/gemini-2.5-flash';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte mimetype de WhatsApp al formato que espera OpenRouter input_audio.
|
||||
*/
|
||||
mimeToAudioFormat(mimeType: string): string {
|
||||
const base = mimeType.toLowerCase().split(";")[0].trim();
|
||||
const map: Record<string, string> = {
|
||||
"audio/ogg": "ogg",
|
||||
"audio/opus": "ogg",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/mp3": "mp3",
|
||||
"audio/mp4": "m4a",
|
||||
"audio/aac": "aac",
|
||||
"audio/wav": "wav",
|
||||
"audio/webm": "webm",
|
||||
"audio/flac": "flac",
|
||||
};
|
||||
return map[base] ?? "ogg";
|
||||
const base = mimeType.toLowerCase().split(';')[0].trim();
|
||||
const map: Record<string, string> = { 'audio/ogg': 'ogg', 'audio/opus': 'ogg', 'audio/mpeg': 'mp3', 'audio/mp3': 'mp3', 'audio/mp4': 'm4a', 'audio/aac': 'aac', 'audio/wav': 'wav', 'audio/webm': 'webm', 'audio/flac': 'flac' };
|
||||
return map[base] ?? 'ogg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina encabezados y formato que el modelo pueda añadir a la transcripcion.
|
||||
*/
|
||||
limpiarTranscripcion(texto: string): string {
|
||||
return texto
|
||||
.replace(/^#+\s*transcripci[oó]n\s*:?\s*\n?/gim, "")
|
||||
.replace(/^\*\*transcripci[oó]n\*\*\s*:?\s*\n?/gim, "")
|
||||
.replace(/^transcripci[oó]n\s*:?\s*\n?/gim, "")
|
||||
.replace(/^```[\s\S]*?\n/g, "")
|
||||
.replace(/\n```$/g, "")
|
||||
.replace(/^["']|["']$/g, "")
|
||||
.trim();
|
||||
return texto.replace(/^#+\s*transcripci[oó]n\s*:?\s*\n?/gim, '')
|
||||
.replace(/^\*\*transcripci[oó]n\*\*\s*:?\s*\n?/gim, '')
|
||||
.replace(/^transcripci[oó]n\s*:?\s*\n?/gim, '')
|
||||
.replace(/^```[\s\S]*?\n/g, '').replace(/\n```$/g, '')
|
||||
.replace(/^["']|["']$/g, '').trim();
|
||||
}
|
||||
|
||||
private detectarFormatoPorMagicBytes(buffer: Buffer): string | null {
|
||||
if (
|
||||
buffer.length >= 4 &&
|
||||
buffer.subarray(0, 4).toString("ascii") === "OggS"
|
||||
) {
|
||||
return "ogg";
|
||||
}
|
||||
if (
|
||||
buffer.length >= 3 &&
|
||||
buffer[0] === 0xff &&
|
||||
(buffer[1] & 0xe0) === 0xe0
|
||||
) {
|
||||
return "mp3";
|
||||
}
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
|
||||
buffer.subarray(8, 12).toString("ascii") === "WAVE"
|
||||
) {
|
||||
return "wav";
|
||||
}
|
||||
if (buffer.length >= 4 && buffer.subarray(0, 4).toString('ascii') === 'OggS') return 'ogg';
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) return 'mp3';
|
||||
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WAVE') return 'wav';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribe un audio via OpenRouter input_audio (Gemini por defecto).
|
||||
* Claude no soporta audio en OpenRouter; Luisa sigue usando Claude en el resto del pipeline.
|
||||
*/
|
||||
async transcribirAudio(
|
||||
audioBuffer: Buffer,
|
||||
mimeType = "audio/ogg; codecs=opus",
|
||||
): Promise<string> {
|
||||
const FALLBACK =
|
||||
"No te he oido bien, me lo repites?";
|
||||
|
||||
async transcribirAudio(audioBuffer: Buffer, mimeType = 'audio/ogg; codecs=opus'): Promise<string> {
|
||||
const FALLBACK = 'No te he oido bien, me lo repites?';
|
||||
const formatFromMime = this.mimeToAudioFormat(mimeType);
|
||||
const formatFromMagic = this.detectarFormatoPorMagicBytes(audioBuffer);
|
||||
const format = formatFromMagic ?? formatFromMime;
|
||||
const base64Audio = audioBuffer.toString("base64");
|
||||
const base64Audio = audioBuffer.toString('base64');
|
||||
const model = this.getModeloTranscripcion();
|
||||
|
||||
this.logger.log(
|
||||
`[AUDIO 2/4] MediaService.transcribirAudio — buffer=${audioBuffer.length} bytes, mime=${mimeType}, format=${format}, magic=${formatFromMagic ?? "no detectado"}, base64=${base64Audio.length} chars, modelo=${model}`,
|
||||
);
|
||||
if (audioBuffer.length < 100) return FALLBACK;
|
||||
|
||||
if (audioBuffer.length < 100) {
|
||||
this.logger.warn(
|
||||
`[AUDIO 2/4] Buffer demasiado pequeno (${audioBuffer.length} bytes), abortando transcripcion`,
|
||||
);
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
const systemPrompt =
|
||||
"Eres un transcriptor de voz para usuarios de Madrid y Espana. " +
|
||||
"Transcribe en espanol peninsular tal como se habla, conservando coloquialismos, " +
|
||||
"muletillas y jerga (vale, tio, guay, mola, etc.) sin corregir ni formalizar. " +
|
||||
"Responde unicamente con las palabras dichas, sin titulos, markdown, comillas ni explicaciones.";
|
||||
|
||||
const userPrompt =
|
||||
"Transcribe exactamente lo que dice la persona en este audio. " +
|
||||
"Es espanol de Espana, posiblemente con tono coloquial madrileño. " +
|
||||
"Devuelve solo las palabras habladas, tal cual, nada mas.";
|
||||
const systemPrompt = 'Eres un transcriptor de voz para usuarios de Madrid y Espana. Transcribe en espanol peninsular tal como se habla, conservando coloquialismos, muletillas y jerga (vale, tio, guay, mola, etc.) sin corregir ni formalizar. Responde unicamente con las palabras dichas, sin titulos, markdown, comillas ni explicaciones.';
|
||||
const userPrompt = 'Transcribe exactamente lo que dice la persona en este audio. Es espanol de Espana, posiblemente con tono coloquial madrileño. Devuelve solo las palabras habladas, tal cual, nada mas.';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
const response = await axios.post(this.OPENROUTER_URL, {
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: userPrompt },
|
||||
{
|
||||
type: "input_audio",
|
||||
input_audio: {
|
||||
data: base64Audio,
|
||||
format,
|
||||
},
|
||||
},
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: [{ type: 'text', text: userPrompt }, { type: 'input_audio', input_audio: { data: base64Audio, format } }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 512,
|
||||
temperature: 0,
|
||||
};
|
||||
|
||||
this.logger.debug(
|
||||
`[AUDIO 3/4] Enviando a OpenRouter — endpoint=${this.OPENROUTER_URL}, content_type=input_audio, format=${format}`,
|
||||
);
|
||||
|
||||
const response = await axios.post(this.OPENROUTER_URL, payload, {
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
const raw: string =
|
||||
response.data.choices?.[0]?.message?.content?.trim() ?? "";
|
||||
const modeloUsado = response.data.model ?? model;
|
||||
|
||||
this.logger.log(
|
||||
`[AUDIO 3/4] Respuesta OpenRouter — modelo=${modeloUsado}, raw_length=${raw.length}, raw_preview="${raw.slice(0, 120).replace(/\n/g, "\\n")}"`,
|
||||
);
|
||||
|
||||
if (!raw) {
|
||||
this.logger.warn(
|
||||
"[AUDIO 4/4] Modelo devolvio respuesta vacia para el audio",
|
||||
);
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
const transcripcion = this.limpiarTranscripcion(raw);
|
||||
|
||||
this.logger.log(
|
||||
`[AUDIO 4/4] Transcripcion final — length=${transcripcion.length}, texto="${transcripcion.slice(0, 200).replace(/\n/g, "\\n")}"`,
|
||||
);
|
||||
|
||||
if (!transcripcion) {
|
||||
this.logger.warn(
|
||||
"[AUDIO 4/4] Transcripcion vacia tras limpieza, usando fallback",
|
||||
);
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
return transcripcion;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[AUDIO 3/4] Error transcribiendo audio: ${error.message}`,
|
||||
error.response?.data,
|
||||
);
|
||||
max_tokens: 512, temperature: 0,
|
||||
}, { headers: this.headers });
|
||||
const raw: string = response.data.choices?.[0]?.message?.content?.trim() ?? '';
|
||||
if (!raw) return FALLBACK;
|
||||
return this.limpiarTranscripcion(raw) || FALLBACK;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error transcribiendo audio: ${error.message}`);
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infiere informacion de una imagen segun el estado actual del lead.
|
||||
*/
|
||||
async inferirImagen(
|
||||
imagenBuffer: Buffer,
|
||||
mimeType = "image/jpeg",
|
||||
estadoActual: EstadoLead = "en_proceso",
|
||||
): Promise<string> {
|
||||
const FALLBACK =
|
||||
"Recibi tu imagen pero no pude analizarla bien. Puedes describirme lo que muestra?";
|
||||
|
||||
async inferirImagen(imagenBuffer: Buffer, mimeType = 'image/jpeg', estadoActual = 'en_proceso'): Promise<string> {
|
||||
const FALLBACK = 'Recibi tu imagen pero no pude analizarla bien. Puedes describirme lo que muestra?';
|
||||
const promptPorEstado: Record<string, string> = {
|
||||
nuevo:
|
||||
"Describe brevemente que tipo de espacio se ve en esta imagen y sus caracteristicas principales.",
|
||||
en_proceso:
|
||||
"Describe el espacio que aparece en la imagen: tipo de habitacion, materiales, estado actual, tamano aproximado.",
|
||||
recopilando_datos:
|
||||
"Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservacion.",
|
||||
completado:
|
||||
"Describe lo que ves en esta imagen relacionado con reformas o diseno de interiores.",
|
||||
no_viable: "Describe brevemente que muestra esta imagen.",
|
||||
perdido: "Describe brevemente que muestra esta imagen.",
|
||||
nuevo: 'Describe brevemente que tipo de espacio se ve en esta imagen y sus caracteristicas principales.',
|
||||
en_proceso: 'Describe el espacio que aparece en la imagen: tipo de habitacion, materiales, estado actual, tamano aproximado.',
|
||||
recopilando_datos: 'Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservacion.',
|
||||
completado: 'Describe lo que ves en esta imagen relacionado con reformas o diseno de interiores.',
|
||||
no_viable: 'Describe brevemente que muestra esta imagen.',
|
||||
perdido: 'Describe brevemente que muestra esta imagen.',
|
||||
};
|
||||
|
||||
const promptDeVision =
|
||||
promptPorEstado[estadoActual] ||
|
||||
"Describe que ves en esta imagen en el contexto de una reforma de hogar.";
|
||||
const promptDeVision = promptPorEstado[estadoActual] || 'Describe que ves en esta imagen en el contexto de una reforma de hogar.';
|
||||
|
||||
try {
|
||||
const base64Imagen = imagenBuffer.toString("base64");
|
||||
|
||||
const response = await axios.post(
|
||||
this.OPENROUTER_URL,
|
||||
{
|
||||
model:
|
||||
process.env.MODEL_GENERADOR ||
|
||||
process.env.MODEL ||
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: promptDeVision },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${mimeType};base64,${base64Imagen}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
const base64Imagen = imagenBuffer.toString('base64');
|
||||
const response = await axios.post(this.OPENROUTER_URL, {
|
||||
model: process.env.MODEL_GENERADOR || process.env.MODEL || 'anthropic/claude-sonnet-4-5',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: promptDeVision }, { type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Imagen}` } }] }],
|
||||
max_tokens: 512,
|
||||
},
|
||||
{ headers: this.headers },
|
||||
);
|
||||
|
||||
const inferencia: string =
|
||||
response.data.choices?.[0]?.message?.content?.trim();
|
||||
|
||||
if (!inferencia) {
|
||||
this.logger.warn("Claude devolvio respuesta vacia para la imagen");
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Imagen inferida correctamente (${inferencia.length} chars)`,
|
||||
);
|
||||
return inferencia;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error analizando imagen: ${error.message}`,
|
||||
error.response?.data,
|
||||
);
|
||||
}, { headers: this.headers });
|
||||
const inferencia: string = response.data.choices?.[0]?.message?.content?.trim();
|
||||
return inferencia || FALLBACK;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error analizando imagen: ${error.message}`);
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SchedulerService } from './scheduler.service';
|
||||
import { LeadsModule } from '../leads/leads.module';
|
||||
import { ConversacionModule } from '../conversacion/conversacion.module';
|
||||
import { WhatsappModule } from '../whatsapp/whatsapp.module';
|
||||
import { ClaudeModule } from '../claude/claude.module';
|
||||
|
||||
@Module({
|
||||
imports: [LeadsModule, ConversacionModule, WhatsappModule, ClaudeModule],
|
||||
providers: [SchedulerService],
|
||||
})
|
||||
export class SchedulerModule {}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { LeadsService } from '../leads/leads.service';
|
||||
import { ConversacionService } from '../conversacion/conversacion.service';
|
||||
import { WhatsappService } from '../whatsapp/whatsapp.service';
|
||||
import { ClaudeService } from '../claude/claude.service';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulerService {
|
||||
private readonly logger = new Logger(SchedulerService.name);
|
||||
|
||||
constructor(
|
||||
private readonly leadsService: LeadsService,
|
||||
private readonly conversacionService: ConversacionService,
|
||||
private readonly whatsappService: WhatsappService,
|
||||
private readonly claudeService: ClaudeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Cada 5 minutos:
|
||||
* 1. Busca leads con estado_actual = 'nuevo'
|
||||
* 2. Los marca como 'en_proceso'
|
||||
* 3. Les envía el mensaje de APERTURA de Luisa
|
||||
*
|
||||
* También marca como perdidos los leads en_proceso sin actividad > 48h.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||
async procesarLeadsNuevos(): Promise<void> {
|
||||
this.logger.log('[Scheduler] Buscando leads nuevos...');
|
||||
|
||||
// Primero limpiar leads inactivos
|
||||
await this.leadsService.marcarLeadsPerdidos();
|
||||
|
||||
// Obtener leads nuevos
|
||||
const leadsNuevos = await this.leadsService.findByEstado('nuevo');
|
||||
|
||||
if (leadsNuevos.length === 0) {
|
||||
this.logger.log('[Scheduler] No hay leads nuevos.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[Scheduler] Procesando ${leadsNuevos.length} lead(s) nuevo(s).`,
|
||||
);
|
||||
|
||||
for (const lead of leadsNuevos) {
|
||||
try {
|
||||
// Marcar como en_proceso antes de hacer nada
|
||||
await this.leadsService.updateEstado(lead, 'en_proceso');
|
||||
this.logger.log(
|
||||
`[Scheduler] Lead id=${lead.id} marcado como en_proceso.`,
|
||||
);
|
||||
|
||||
// Generar mensaje de apertura con Claude usando contexto mínimo
|
||||
const historialVacio: Array<{ role: string; content: string }> = [];
|
||||
const mensajeDeApertura =
|
||||
'APERTURA: Este es el primer mensaje. Preséntate y comienza el flujo de cualificación.';
|
||||
|
||||
const { respuesta } = await this.claudeService.llamarClaude(
|
||||
lead,
|
||||
historialVacio,
|
||||
mensajeDeApertura,
|
||||
);
|
||||
|
||||
// Guardar el mensaje de apertura en historial (como assistant)
|
||||
await this.conversacionService.guardarMensaje(
|
||||
lead.id,
|
||||
'assistant',
|
||||
respuesta,
|
||||
);
|
||||
|
||||
// Enviar por WhatsApp
|
||||
await this.whatsappService.enviarApertura(lead.telefono, respuesta);
|
||||
|
||||
this.logger.log(
|
||||
`[Scheduler] Apertura enviada a lead id=${lead.id} (${lead.telefono}).`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[Scheduler] Error procesando lead id=${lead.id}: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
mvp/Whatsapp-bot/src/webhook/webhook-listener.ts
Normal file
85
mvp/Whatsapp-bot/src/webhook/webhook-listener.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import * as http from 'http';
|
||||
import { ApiClient } from '../api/api-client.service';
|
||||
|
||||
@Injectable()
|
||||
export class WebhookListener implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(WebhookListener.name);
|
||||
private server: http.Server | null = null;
|
||||
|
||||
constructor(private readonly api: ApiClient) {}
|
||||
|
||||
onApplicationBootstrap() {
|
||||
const port = parseInt(process.env.WEBHOOK_PORT || '3001', 10);
|
||||
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
||||
this.server.listen(port, () => {
|
||||
this.logger.log(`Webhook listener en puerto ${port}`);
|
||||
this.logger.log(`WHATSAPP_START → POST /whatsapp-start`);
|
||||
this.logger.log(`WHATSAPP_PDF → POST /whatsapp-pdf`);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405).end('Method Not Allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', (chunk) => (body += chunk));
|
||||
req.on('end', async () => {
|
||||
let payload: any;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'JSON invalido' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = req.url || '';
|
||||
try {
|
||||
if (url === '/whatsapp-start') {
|
||||
await this.handleWhatsappStart(payload);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
||||
} else if (url === '/whatsapp-pdf') {
|
||||
await this.handleWhatsappPdf(payload);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
||||
} else {
|
||||
res.writeHead(404).end('Not Found');
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Error handling ${url}: ${err.message}`);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, error: err.message }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
private leadSessions = new Map<string, { leadId: string; telefono: string; nombre: string; jid: string | null }>();
|
||||
|
||||
private async handleWhatsappStart(payload: { leadId: string; telefono: string; nombre: string; empresa: string }) {
|
||||
const { leadId, telefono, nombre } = payload;
|
||||
this.logger.log(`[START] leadId=${leadId}, telefono=${telefono}, nombre=${nombre}`);
|
||||
|
||||
this.leadSessions.set(telefono, { leadId, telefono, nombre, jid: null });
|
||||
this.logger.log(`Lead ${leadId} registrado en sesiones.`);
|
||||
}
|
||||
|
||||
getLeadIdByTelefono(telefono: string): string | null {
|
||||
return this.leadSessions.get(telefono)?.leadId ?? null;
|
||||
}
|
||||
|
||||
registerJid(telefono: string, jid: string) {
|
||||
const session = this.leadSessions.get(telefono);
|
||||
if (session) {
|
||||
session.jid = jid;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWhatsappPdf(payload: { leadId: string; telefono: string; pdfBase64: string; filename: string }) {
|
||||
this.logger.log(`[PDF] leadId=${payload.leadId}, filename=${payload.filename}`);
|
||||
const { pdfEmitter } = await import('../whatsapp/whatsapp.service');
|
||||
const telefono = payload.telefono.startsWith('+') ? payload.telefono.slice(1) : payload.telefono;
|
||||
pdfEmitter.emit('pdf', { ...payload, telefono });
|
||||
}
|
||||
}
|
||||
10
mvp/Whatsapp-bot/src/webhook/webhook.module.ts
Normal file
10
mvp/Whatsapp-bot/src/webhook/webhook.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WebhookListener } from './webhook-listener';
|
||||
import { ApiModule } from '../api/api.module';
|
||||
|
||||
@Module({
|
||||
imports: [ApiModule],
|
||||
providers: [WebhookListener],
|
||||
exports: [WebhookListener],
|
||||
})
|
||||
export class WebhookModule {}
|
||||
@@ -5,9 +5,10 @@ import { LeadsModule } from '../leads/leads.module';
|
||||
import { ConversacionModule } from '../conversacion/conversacion.module';
|
||||
import { ClaudeModule } from '../claude/claude.module';
|
||||
import { MediaModule } from '../media/media.module';
|
||||
import { WebhookModule } from '../webhook/webhook.module';
|
||||
|
||||
@Module({
|
||||
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule],
|
||||
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule, WebhookModule],
|
||||
providers: [WhatsappService, WhatsappDebounceService],
|
||||
exports: [WhatsappService],
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from "@nestjs/common";
|
||||
} from '@nestjs/common';
|
||||
import makeWASocket, {
|
||||
DisconnectReason,
|
||||
useMultiFileAuthState,
|
||||
@@ -11,33 +12,42 @@ import makeWASocket, {
|
||||
WASocket,
|
||||
downloadMediaMessage,
|
||||
normalizeMessageContent,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import { Boom } from "@hapi/boom";
|
||||
import * as path from "path";
|
||||
const pino = require("pino");
|
||||
const QRCode = require("qrcode-terminal");
|
||||
import { LeadsService } from "../leads/leads.service";
|
||||
import { ConversacionService } from "../conversacion/conversacion.service";
|
||||
import { ClaudeService } from "../claude/claude.service";
|
||||
import { MediaService } from "../media/media.service";
|
||||
import { WhatsappDebounceService } from "./whatsapp-debounce.service";
|
||||
import { wrapSocket } from "baileys-antiban";
|
||||
} from '@whiskeysockets/baileys';
|
||||
import { Boom } from '@hapi/boom';
|
||||
import * as path from 'path';
|
||||
const pino = require('pino');
|
||||
const QRCode = require('qrcode-terminal');
|
||||
import { LeadsService } from '../leads/leads.service';
|
||||
import { ConversacionService } from '../conversacion/conversacion.service';
|
||||
import { ClaudeService } from '../claude/claude.service';
|
||||
import { MediaService } from '../media/media.service';
|
||||
import { WhatsappDebounceService } from './whatsapp-debounce.service';
|
||||
import { WebhookListener } from '../webhook/webhook-listener';
|
||||
import { ApiClient } from '../api/api-client.service';
|
||||
import { wrapSocket } from 'baileys-antiban';
|
||||
|
||||
const ESTADOS_TERMINALES = [
|
||||
"completado",
|
||||
"no_viable",
|
||||
"perdido",
|
||||
"fin_viable",
|
||||
"fin_no_viable",
|
||||
];
|
||||
export const pdfEmitter = new EventEmitter();
|
||||
|
||||
interface LeadContext {
|
||||
leadId: string;
|
||||
telefono: string;
|
||||
nombre: string;
|
||||
botStep: string;
|
||||
viable: boolean | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(WhatsappService.name);
|
||||
private sock: WASocket | null = null;
|
||||
private authDir = path.join(process.cwd(), "auth_info_baileys");
|
||||
private authDir = path.join(process.cwd(), 'auth_info_baileys');
|
||||
private readonly ultimoMsgPorJid = new Map<string, any>();
|
||||
private baileysLogger = pino({ level: "info" });
|
||||
private baileysLogger = pino({ level: 'info' });
|
||||
|
||||
// leadId por JID
|
||||
private readonly jidToLeadId = new Map<string, string>();
|
||||
// contexto de lead por leadId
|
||||
private readonly leadCache = new Map<string, LeadContext>();
|
||||
|
||||
constructor(
|
||||
private readonly leadsService: LeadsService,
|
||||
@@ -45,20 +55,51 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly claudeService: ClaudeService,
|
||||
private readonly mediaService: MediaService,
|
||||
private readonly debounceService: WhatsappDebounceService,
|
||||
private readonly webhookListener: WebhookListener,
|
||||
private readonly api: ApiClient,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.conectar();
|
||||
this.escucharPdf();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.sock) {
|
||||
this.sock.end(undefined);
|
||||
if (this.sock) this.sock.end(undefined);
|
||||
}
|
||||
|
||||
private escucharPdf() {
|
||||
pdfEmitter.on('pdf', async (payload: { leadId: string; telefono: string; pdfBase64: string; filename: string }) => {
|
||||
this.logger.log(`[PDF] Recibido para leadId=${payload.leadId}`);
|
||||
// Buscar JID por teléfono
|
||||
let jid: string | null = null;
|
||||
for (const [j, lid] of this.jidToLeadId) {
|
||||
if (lid === payload.leadId) {
|
||||
jid = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!jid) {
|
||||
jid = `${payload.telefono}@s.whatsapp.net`;
|
||||
}
|
||||
if (!this.sock) return;
|
||||
try {
|
||||
const safeSock = wrapSocket(this.sock);
|
||||
await safeSock.sendMessage(jid, {
|
||||
document: Buffer.from(payload.pdfBase64, 'base64'),
|
||||
mimetype: 'application/pdf',
|
||||
fileName: payload.filename,
|
||||
caption: 'Aquí tienes tu presupuesto. Si tienes cualquier duda, estamos aquí.',
|
||||
});
|
||||
this.logger.log(`PDF enviado a ${jid}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Error enviando PDF a ${jid}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private normalizarTelefono(jid: string): string {
|
||||
return jid.split("@")[0].replace(/\D/g, "");
|
||||
return jid.split('@')[0].replace(/\D/g, '');
|
||||
}
|
||||
|
||||
private calcularDelayEscritura(longitudTexto: number): number {
|
||||
@@ -76,7 +117,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
|
||||
this.baileysLogger = pino({ level: "info" }) as any;
|
||||
this.baileysLogger = pino({ level: 'info' }) as any;
|
||||
|
||||
this.sock = makeWASocket({
|
||||
version,
|
||||
@@ -88,56 +129,37 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
syncFullHistory: false,
|
||||
});
|
||||
|
||||
this.sock.ev.on("creds.update", saveCreds);
|
||||
this.sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
this.sock.ev.on("connection.update", (update) => {
|
||||
this.sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
QRCode.generate(qr, { small: true });
|
||||
console.log("\n📲 Escanea este QR con WhatsApp\n");
|
||||
console.log('\n📲 Escanea este QR con WhatsApp\n');
|
||||
}
|
||||
|
||||
if (connection === "close") {
|
||||
if (connection === 'close') {
|
||||
const shouldReconnect =
|
||||
(lastDisconnect?.error as Boom)?.output?.statusCode !==
|
||||
DisconnectReason.loggedOut;
|
||||
|
||||
this.logger.warn(
|
||||
`Conexion cerrada. Reconectar: ${shouldReconnect}. Razon: ${lastDisconnect?.error?.message}`,
|
||||
);
|
||||
|
||||
if (shouldReconnect) {
|
||||
setTimeout(() => this.conectar(), 5000);
|
||||
} else {
|
||||
this.logger.error(
|
||||
"Sesion cerrada (logged out). Elimina auth_info_baileys y reinicia.",
|
||||
);
|
||||
}
|
||||
} else if (connection === "open") {
|
||||
this.logger.log(
|
||||
"✅ WhatsApp conectado. Luisa esta lista para recibir mensajes.",
|
||||
);
|
||||
(lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut;
|
||||
this.logger.warn(`Conexion cerrada. Reconectar: ${shouldReconnect}.`);
|
||||
if (shouldReconnect) setTimeout(() => this.conectar(), 5000);
|
||||
else this.logger.error('Sesion cerrada (logged out).');
|
||||
} else if (connection === 'open') {
|
||||
this.logger.log('✅ WhatsApp conectado. Luisa esta lista.');
|
||||
}
|
||||
});
|
||||
|
||||
this.sock.ev.on("messages.upsert", async ({ messages, type }) => {
|
||||
if (type !== "notify") return;
|
||||
|
||||
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||
if (type !== 'notify') return;
|
||||
for (const msg of messages) {
|
||||
if (msg.key.fromMe) continue;
|
||||
if (!msg.key.remoteJid) continue;
|
||||
if (msg.key.remoteJid.includes("@g.us")) continue;
|
||||
if (msg.key.remoteJid.includes('@g.us')) continue;
|
||||
|
||||
const telefonoNormalizado = this.normalizarTelefono(msg.key.remoteJid);
|
||||
const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, "");
|
||||
|
||||
if (allowedNumber && telefonoNormalizado !== allowedNumber) {
|
||||
this.logger.debug(
|
||||
`Mensaje ignorado: ${telefonoNormalizado} no coincide con ALLOWED_NUMBER`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, '');
|
||||
if (allowedNumber && telefonoNormalizado !== allowedNumber) continue;
|
||||
|
||||
await this.encolarMensaje(msg);
|
||||
}
|
||||
@@ -147,21 +169,15 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
private extraerTextoPlano(msg: any): string | null {
|
||||
const msgContent = msg.message;
|
||||
if (!msgContent) return null;
|
||||
|
||||
if (msgContent.conversation || msgContent.extendedTextMessage) {
|
||||
const texto =
|
||||
msgContent.conversation || msgContent.extendedTextMessage?.text || "";
|
||||
const texto = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
|
||||
return texto.trim() ? texto : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private crearMsgConTexto(msg: any, texto: string): any {
|
||||
return {
|
||||
...msg,
|
||||
message: { conversation: texto },
|
||||
};
|
||||
return { ...msg, message: { conversation: texto } };
|
||||
}
|
||||
|
||||
private async encolarMensaje(msg: any): Promise<void> {
|
||||
@@ -174,7 +190,6 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
this.ultimoMsgPorJid.set(jid, msg);
|
||||
|
||||
await this.debounceService.add(jid, textoPlano, async (combinedMessage) => {
|
||||
const baseMsg = this.ultimoMsgPorJid.get(jid) ?? msg;
|
||||
this.ultimoMsgPorJid.delete(jid);
|
||||
@@ -182,179 +197,184 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private async procesarMensaje(msg: any): Promise<void> {
|
||||
const jid = msg.key.remoteJid!;
|
||||
private async getOrCreateContext(telefono: string, jid: string): Promise<LeadContext | null> {
|
||||
const leadId = this.webhookListener.getLeadIdByTelefono(telefono);
|
||||
|
||||
if (jid.includes("@g.us")) return;
|
||||
|
||||
const telefono = jid.split("@")[0];
|
||||
|
||||
try {
|
||||
let lead = await this.leadsService.findOrCreate(telefono);
|
||||
|
||||
if (ESTADOS_TERMINALES.includes(lead.estado_actual)) {
|
||||
this.logger.log(
|
||||
`Lead id=${lead.id} en estado=${lead.estado_actual}. Ignorando.`,
|
||||
);
|
||||
return;
|
||||
if (!leadId) {
|
||||
this.logger.log(`Mensaje ignorado de ${telefono}: lead no registrado. Debe iniciarse desde la web.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let textoNormalizado = "";
|
||||
const msgContent = normalizeMessageContent(msg.message);
|
||||
this.webhookListener.registerJid(telefono, jid);
|
||||
this.jidToLeadId.set(jid, leadId);
|
||||
|
||||
let ctx = this.leadCache.get(leadId);
|
||||
if (!ctx) {
|
||||
const lead = await this.api.getLead(leadId);
|
||||
ctx = {
|
||||
leadId,
|
||||
telefono,
|
||||
nombre: lead?.nombre || '',
|
||||
botStep: lead?.botStep || 'nuevo',
|
||||
viable: lead?.viable ?? null,
|
||||
};
|
||||
this.leadCache.set(leadId, ctx);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private async procesarMensaje(msg: any): Promise<void> {
|
||||
const jid = msg.key.remoteJid!;
|
||||
if (jid.includes('@g.us')) return;
|
||||
|
||||
const telefono = jid.split('@')[0];
|
||||
|
||||
try {
|
||||
const ctx = await this.getOrCreateContext(telefono, jid);
|
||||
if (!ctx) return;
|
||||
|
||||
const primerMensajeDeUsuario = !this.jidToLeadId.has(jid);
|
||||
|
||||
let textoNormalizado = '';
|
||||
const msgContent = normalizeMessageContent(msg.message);
|
||||
if (!msgContent) return;
|
||||
|
||||
if (msgContent.conversation || msgContent.extendedTextMessage) {
|
||||
textoNormalizado =
|
||||
msgContent.conversation || msgContent.extendedTextMessage?.text || "";
|
||||
textoNormalizado = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
|
||||
} else if (msgContent.audioMessage) {
|
||||
const audioMeta = msgContent.audioMessage;
|
||||
const mimeType = audioMeta.mimetype || "audio/ogg; codecs=opus";
|
||||
|
||||
this.logger.log(
|
||||
`[AUDIO 1/4] Recibido — lead=${lead.id}, ptt=${audioMeta.ptt ?? false}, seconds=${audioMeta.seconds ?? "?"}, mimetype=${mimeType}, fileLength=${audioMeta.fileLength ?? "?"}, url=${audioMeta.url ? "si" : "no"}`,
|
||||
);
|
||||
|
||||
if (!this.sock) {
|
||||
this.logger.error("[AUDIO 1/4] Socket no disponible para descargar audio");
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = await downloadMediaMessage(
|
||||
msg as any,
|
||||
"buffer",
|
||||
{},
|
||||
{
|
||||
logger: this.baileysLogger,
|
||||
reuploadRequest: this.sock.updateMediaMessage,
|
||||
},
|
||||
);
|
||||
|
||||
const audioBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||
const magicHex = audioBuffer.subarray(0, 4).toString("hex");
|
||||
|
||||
this.logger.log(
|
||||
`[AUDIO 1/4] Buffer descargado — size=${audioBuffer.length} bytes, magic_hex=${magicHex}, esperado_ogg=4f676753`,
|
||||
);
|
||||
|
||||
textoNormalizado = await this.mediaService.transcribirAudio(
|
||||
audioBuffer,
|
||||
mimeType,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`[AUDIO 1/4] Transcripcion recibida en procesarMensaje — "${textoNormalizado.slice(0, 200).replace(/\n/g, "\\n")}"`,
|
||||
);
|
||||
} else if (msgContent.imageMessage) {
|
||||
this.logger.log(
|
||||
`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`,
|
||||
);
|
||||
|
||||
const mimeType = audioMeta.mimetype || 'audio/ogg; codecs=opus';
|
||||
this.logger.log(`[AUDIO] Recibido — lead=${ctx.leadId}`);
|
||||
if (!this.sock) return;
|
||||
|
||||
const buffer = await downloadMediaMessage(
|
||||
msg as any,
|
||||
"buffer",
|
||||
{},
|
||||
{
|
||||
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
|
||||
logger: this.baileysLogger,
|
||||
reuploadRequest: this.sock.updateMediaMessage,
|
||||
},
|
||||
);
|
||||
const mimeType = msgContent.imageMessage.mimetype || "image/jpeg";
|
||||
});
|
||||
const audioBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||
textoNormalizado = await this.mediaService.transcribirAudio(audioBuffer, mimeType);
|
||||
} else if (msgContent.imageMessage) {
|
||||
this.logger.log(`Imagen recibida de lead ${ctx.leadId}`);
|
||||
if (!this.sock) return;
|
||||
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
|
||||
logger: this.baileysLogger,
|
||||
reuploadRequest: this.sock.updateMediaMessage,
|
||||
});
|
||||
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
|
||||
textoNormalizado = await this.mediaService.inferirImagen(
|
||||
Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer),
|
||||
mimeType,
|
||||
lead.estado_actual,
|
||||
'en_proceso',
|
||||
);
|
||||
if (msgContent.imageMessage.caption) {
|
||||
textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`;
|
||||
}
|
||||
} else {
|
||||
this.logger.log(
|
||||
`Tipo de mensaje no soportado de lead id=${lead.id}. Ignorando.`,
|
||||
);
|
||||
this.logger.log(`Tipo de mensaje no soportado de lead ${ctx.leadId}. Ignorando.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!textoNormalizado.trim()) return;
|
||||
|
||||
this.logger.log(`USUARIO [${telefono}]: ${textoNormalizado}`);
|
||||
|
||||
await this.conversacionService.guardarMensaje(
|
||||
lead.id,
|
||||
"user",
|
||||
textoNormalizado,
|
||||
);
|
||||
if (primerMensajeDeUsuario) {
|
||||
await this.api.registrarIntento(ctx.leadId, 'whatsapp', 1, 'exitoso', true);
|
||||
}
|
||||
|
||||
const historial =
|
||||
await this.conversacionService.obtenerHistorialComoMessages(lead.id);
|
||||
if (msgContent.imageMessage) {
|
||||
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
|
||||
logger: this.baileysLogger,
|
||||
reuploadRequest: this.sock.updateMediaMessage,
|
||||
});
|
||||
const base64 = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64');
|
||||
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
|
||||
await this.api.enviarIngesta(ctx.leadId, [{
|
||||
tipo: 'foto',
|
||||
imagen: `data:${mimeType};base64,${base64}`,
|
||||
zona: 'otro',
|
||||
momento: 'antes',
|
||||
}]);
|
||||
}
|
||||
|
||||
const { respuesta, entidad, viable, nuevoEstado } =
|
||||
await this.claudeService.llamarClaude(
|
||||
lead,
|
||||
await this.conversacionService.guardarMensaje(ctx.leadId, 'user', textoNormalizado, {
|
||||
botStep: ctx.botStep,
|
||||
});
|
||||
|
||||
const historial = await this.conversacionService.obtenerHistorialComoMessages(ctx.leadId);
|
||||
|
||||
const leadParaClaude = {
|
||||
id: ctx.leadId,
|
||||
telefono: ctx.telefono,
|
||||
nombre: ctx.nombre,
|
||||
estado_actual: ctx.botStep || 'nuevo',
|
||||
espacio: null as string | null,
|
||||
rango_m2: null as string | null,
|
||||
estilo: null as string | null,
|
||||
urgencia: null as string | null,
|
||||
presupuesto_declarado: null as string | null,
|
||||
viable: ctx.viable as boolean | null,
|
||||
email: null as string | null,
|
||||
};
|
||||
|
||||
const { respuesta, entidad, viable, nuevoEstado } = await this.claudeService.llamarClaude(
|
||||
leadParaClaude as any,
|
||||
historial.slice(0, -1),
|
||||
textoNormalizado,
|
||||
);
|
||||
|
||||
this.logger.log(`LUISA [${telefono}]: ${respuesta}`);
|
||||
|
||||
if (
|
||||
(entidad && Object.keys(entidad).length > 0) ||
|
||||
nuevoEstado ||
|
||||
(viable !== undefined && viable !== null)
|
||||
) {
|
||||
lead = await this.leadsService.persistirTurno(lead.id, entidad ?? {}, {
|
||||
nuevoEstado,
|
||||
viable,
|
||||
});
|
||||
this.logger.log(
|
||||
`Lead id=${lead.id} en DB — estado=${lead.estado_actual}, espacio=${lead.espacio ?? "-"}, rango_m2=${lead.rango_m2 ?? "-"}, estilo=${lead.estilo ?? "-"}, urgencia=${lead.urgencia ?? "-"}, presupuesto=${lead.presupuesto_declarado ?? "-"}`,
|
||||
);
|
||||
if ((entidad && Object.keys(entidad).length > 0) || nuevoEstado || viable !== undefined) {
|
||||
const entidadMap: Record<string, unknown> = {};
|
||||
if (entidad) {
|
||||
for (const [k, v] of Object.entries(entidad)) {
|
||||
const mapped = this.mapearCampoALegacy(k);
|
||||
entidadMap[mapped] = v;
|
||||
}
|
||||
}
|
||||
await this.leadsService.persistirTurno(ctx.leadId, entidadMap, { nuevoEstado, viable });
|
||||
if (nuevoEstado) ctx.botStep = nuevoEstado;
|
||||
if (viable !== undefined) ctx.viable = viable;
|
||||
this.logger.log(`Lead ${ctx.leadId} persistido — estado=${nuevoEstado || ctx.botStep}`);
|
||||
}
|
||||
|
||||
await this.conversacionService.guardarMensaje(
|
||||
lead.id,
|
||||
"assistant",
|
||||
respuesta,
|
||||
);
|
||||
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', respuesta, {
|
||||
botStep: ctx.botStep,
|
||||
});
|
||||
await this.enviarMensaje(jid, respuesta);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error procesando mensaje de ${telefono}: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error procesando mensaje de ${telefono}: ${error.message}`, error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
private mapearCampoALegacy(campo: string): string {
|
||||
const map: Record<string, string> = {
|
||||
espacio: 'espacio',
|
||||
rango_m2: 'rangoM2',
|
||||
estilo: 'estilo',
|
||||
urgencia: 'urgencia',
|
||||
presupuesto_declarado: 'presupuestoDeclarado',
|
||||
nombre: 'nombre',
|
||||
};
|
||||
return map[campo] || campo;
|
||||
}
|
||||
|
||||
async enviarMensaje(jid: string, texto: string): Promise<void> {
|
||||
if (!this.sock) return;
|
||||
|
||||
try {
|
||||
const jidPresencia = jid.includes("@lid")
|
||||
? `${jid.split("@")[0]}@s.whatsapp.net`
|
||||
const jidPresencia = jid.includes('@lid')
|
||||
? `${jid.split('@')[0]}@s.whatsapp.net`
|
||||
: jid;
|
||||
|
||||
await this.sock.sendPresenceUpdate("composing", jidPresencia);
|
||||
await this.sock.sendPresenceUpdate('composing', jidPresencia);
|
||||
await this.delay(this.calcularDelayEscritura(texto.length));
|
||||
await this.sock.sendPresenceUpdate("paused", jidPresencia);
|
||||
|
||||
await this.sock.sendPresenceUpdate('paused', jidPresencia);
|
||||
const safeSock = wrapSocket(this.sock);
|
||||
await safeSock.sendMessage(jid, { text: texto });
|
||||
this.logger.log(`Mensaje enviado a ${jid}`);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error enviando mensaje a ${jid}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async enviarApertura(
|
||||
telefono: string,
|
||||
mensajeApertura: string,
|
||||
): Promise<void> {
|
||||
const jid = `${telefono}@s.whatsapp.net`;
|
||||
await this.enviarMensaje(jid, mensajeApertura);
|
||||
}
|
||||
|
||||
isConectado(): boolean {
|
||||
return this.sock !== null;
|
||||
}
|
||||
|
||||
1
mvp/Whatsapp-bot/tsconfig.build.tsbuildinfo
Normal file
1
mvp/Whatsapp-bot/tsconfig.build.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
|
||||
1
mvp/Whatsapp-bot/tsconfig.tsbuildinfo
Normal file
1
mvp/Whatsapp-bot/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
742
package-lock.json
generated
Normal file
742
package-lock.json
generated
Normal file
@@ -0,0 +1,742 @@
|
||||
{
|
||||
"name": "landing-page",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "^7.0.0-rc10"
|
||||
}
|
||||
},
|
||||
"node_modules/@borewit/text-codec": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
|
||||
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/memory": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.9.tgz",
|
||||
"integrity": "sha512-HdMx6DoGywB30vacDbBsITbIX4pgFqj1zsrV58jZBUw3klzkNoXhj7qOqAgledhxG7YZI5rBSJg7Zp8/VG0DuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cacheable/utils": "^2.4.1",
|
||||
"@keyv/bigmap": "^1.3.1",
|
||||
"hookified": "^1.15.1",
|
||||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/memory/node_modules/keyv": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
||||
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/node-cache": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz",
|
||||
"integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cacheable": "^2.3.1",
|
||||
"hookified": "^1.14.0",
|
||||
"keyv": "^5.5.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/node-cache/node_modules/keyv": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
||||
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/utils": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz",
|
||||
"integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hashery": "^1.5.1",
|
||||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/utils/node_modules/keyv": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
||||
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/boom": {
|
||||
"version": "9.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz",
|
||||
"integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "9.x.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||
"integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@keyv/bigmap": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
|
||||
"integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hashery": "^1.4.0",
|
||||
"hookified": "^1.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@keyv/serialize": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
|
||||
"integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/inquire": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
|
||||
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
|
||||
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"token-types": "^6.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/token": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.41",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@whiskeysockets/baileys": {
|
||||
"version": "7.0.0-rc10",
|
||||
"resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc10.tgz",
|
||||
"integrity": "sha512-tVHZRIE06HlQajHcLEsCa+gnH5z+dAXPjwHsGXDNY9/Y0iqbymQzHLvh4tMH/pi/ea/D617qCQhNkDT2B0tufg==",
|
||||
"deprecated": "This version is affected by a zero-day vulnerability that allows spoofing of messages, please update to\n the latest versions (6.7.22^ or 7.0.0-rc12^)! For more information, check out the public advisory at\n https://github.com/WhiskeySockets/Baileys/security/advisories/GHSA-qvv5-jq5g-4cgg",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cacheable/node-cache": "^1.4.0",
|
||||
"@hapi/boom": "^9.1.3",
|
||||
"async-mutex": "^0.5.0",
|
||||
"libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git",
|
||||
"lru-cache": "^11.1.0",
|
||||
"music-metadata": "^11.12.3",
|
||||
"p-queue": "^9.0.0",
|
||||
"pino": "^9.6",
|
||||
"protobufjs": "^7.5.6",
|
||||
"whatsapp-rust-bridge": "0.5.3",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"audio-decode": "^2.1.3",
|
||||
"jimp": "^1.6.1",
|
||||
"link-preview-js": "^3.0.0",
|
||||
"sharp": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"audio-decode": {
|
||||
"optional": true
|
||||
},
|
||||
"jimp": {
|
||||
"optional": true
|
||||
},
|
||||
"link-preview-js": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@whiskeysockets/baileys/node_modules/lru-cache": {
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/async-mutex": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
|
||||
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.5.tgz",
|
||||
"integrity": "sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cacheable/memory": "^2.0.8",
|
||||
"@cacheable/utils": "^2.4.1",
|
||||
"hookified": "^1.15.0",
|
||||
"keyv": "^5.6.0",
|
||||
"qified": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable/node_modules/keyv": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
||||
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/curve25519-js": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz",
|
||||
"integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
"version": "21.3.4",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
|
||||
"integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/inflate": "^0.4.1",
|
||||
"strtok3": "^10.3.4",
|
||||
"token-types": "^6.1.1",
|
||||
"uint8array-extras": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/hashery": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
|
||||
"integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hookified": "^1.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/hookified": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
|
||||
"integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/libsignal": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"curve25519-js": "^0.0.4",
|
||||
"protobufjs": "^7.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/music-metadata": {
|
||||
"version": "11.12.3",
|
||||
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.3.tgz",
|
||||
"integrity": "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
},
|
||||
{
|
||||
"type": "buymeacoffee",
|
||||
"url": "https://buymeacoffee.com/borewit"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@borewit/text-codec": "^0.2.2",
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"content-type": "^1.0.5",
|
||||
"debug": "^4.4.3",
|
||||
"file-type": "^21.3.1",
|
||||
"media-typer": "^1.1.0",
|
||||
"strtok3": "^10.3.4",
|
||||
"token-types": "^6.1.2",
|
||||
"uint8array-extras": "^1.5.0",
|
||||
"win-guid": "^0.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-queue": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz",
|
||||
"integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.4",
|
||||
"p-timeout": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-timeout": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
|
||||
"integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^2.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
|
||||
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.6.2",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.2.tgz",
|
||||
"integrity": "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.1",
|
||||
"@protobufjs/fetch": "^1.1.1",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.2",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qified": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
|
||||
"integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hookified": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/qified/node_modules/hookified": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
|
||||
"integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/strtok3": {
|
||||
"version": "10.3.5",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
|
||||
"integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
||||
"integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/token-types": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
|
||||
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@borewit/text-codec": "^0.2.1",
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"ieee754": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/uint8array-extras": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
|
||||
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/whatsapp-rust-bridge": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/whatsapp-rust-bridge/-/whatsapp-rust-bridge-0.5.3.tgz",
|
||||
"integrity": "sha512-Xb3GAgtWQQJ30oI4a4pjM4+YUeli9CMLTwTIewUrb+AJMFElIkiT5uo+j1Zhc+amiV0Jj+LfX76c/EEZirJbGA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/win-guid": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
|
||||
"integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "^7.0.0-rc10"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user