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_REGLAS=anthropic/claude-haiku-4-5
|
||||||
MODEL_TRANSCRIPCION=google/gemini-2.5-flash
|
MODEL_TRANSCRIPCION=google/gemini-2.5-flash
|
||||||
MODEL=
|
MODEL=
|
||||||
DATABASE_URL=
|
|
||||||
ALLOWED_NUMBER=
|
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",
|
"name": "reformix-luisa-bot",
|
||||||
"version": "1.0.0",
|
"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",
|
"author": "Reformix",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -15,28 +15,20 @@
|
|||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage"
|
||||||
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
|
|
||||||
"migration:generate": "npm run typeorm -- migration:generate -d src/data-source.ts",
|
|
||||||
"migration:run": "npm run typeorm -- migration:run -d src/data-source.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^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",
|
"@whiskeysockets/baileys": "^7.0.0-rc10",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"baileys-antiban": "^3.9.0",
|
"baileys-antiban": "^3.9.0",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"form-data": "^4.0.1",
|
|
||||||
"pg": "^8.12.0",
|
|
||||||
"pino": "^9.3.2",
|
"pino": "^9.3.2",
|
||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1"
|
||||||
"typeorm": "^0.3.20"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
@@ -56,16 +48,10 @@
|
|||||||
"typescript": "^5.5.0"
|
"typescript": "^5.5.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
"js",
|
|
||||||
"json",
|
|
||||||
"ts"
|
|
||||||
],
|
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
|
||||||
},
|
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"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 'dotenv/config';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { ApiModule } from './api/api.module';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
|
||||||
import { LeadsModule } from './leads/leads.module';
|
import { LeadsModule } from './leads/leads.module';
|
||||||
import { ConversacionModule } from './conversacion/conversacion.module';
|
import { ConversacionModule } from './conversacion/conversacion.module';
|
||||||
import { WhatsappModule } from './whatsapp/whatsapp.module';
|
import { WhatsappModule } from './whatsapp/whatsapp.module';
|
||||||
import { ClaudeModule } from './claude/claude.module';
|
import { ClaudeModule } from './claude/claude.module';
|
||||||
import { MediaModule } from './media/media.module';
|
import { MediaModule } from './media/media.module';
|
||||||
import { SchedulerModule } from './scheduler/scheduler.module';
|
import { WebhookModule } from './webhook/webhook.module';
|
||||||
import { Lead } from './leads/lead.entity';
|
|
||||||
import { Conversacion } from './conversacion/conversacion.entity';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ScheduleModule.forRoot(),
|
ApiModule,
|
||||||
TypeOrmModule.forRoot({
|
|
||||||
type: 'postgres',
|
|
||||||
url: process.env.DATABASE_URL,
|
|
||||||
entities: [Lead, Conversacion],
|
|
||||||
synchronize: true, // En produccion usar migrations en lugar de synchronize
|
|
||||||
ssl: process.env.DATABASE_URL?.includes('sslmode=require')
|
|
||||||
? { rejectUnauthorized: false }
|
|
||||||
: false,
|
|
||||||
}),
|
|
||||||
LeadsModule,
|
LeadsModule,
|
||||||
ConversacionModule,
|
ConversacionModule,
|
||||||
WhatsappModule,
|
WhatsappModule,
|
||||||
ClaudeModule,
|
ClaudeModule,
|
||||||
MediaModule,
|
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 fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Lead } from '../leads/lead.entity';
|
|
||||||
import { LeadsService } from '../leads/leads.service';
|
import { LeadsService } from '../leads/leads.service';
|
||||||
|
|
||||||
const DEFAULT_SYSTEM_PROMPT =
|
const DEFAULT_SYSTEM_PROMPT =
|
||||||
@@ -34,9 +33,23 @@ export interface ValidacionResultado {
|
|||||||
viable?: boolean;
|
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 {
|
export interface ClaudeResponse {
|
||||||
respuesta: string;
|
respuesta: string;
|
||||||
entidad?: Partial<Lead>;
|
entidad?: Partial<LeadBasico>;
|
||||||
viable?: boolean;
|
viable?: boolean;
|
||||||
nuevoEstado?: string;
|
nuevoEstado?: string;
|
||||||
}
|
}
|
||||||
@@ -47,73 +60,39 @@ export class ClaudeService implements OnModuleInit {
|
|||||||
private readonly promptsDir = path.join(process.cwd(), 'prompts');
|
private readonly promptsDir = path.join(process.cwd(), 'prompts');
|
||||||
private systemPromptCache = '';
|
private systemPromptCache = '';
|
||||||
private reglasPromptCache = '';
|
private reglasPromptCache = '';
|
||||||
private readonly reintentosPorLead = new Map<
|
private readonly reintentosPorLead = new Map<string, { estado: string; count: number }>();
|
||||||
string,
|
|
||||||
{ estado: string; count: number }
|
|
||||||
>();
|
|
||||||
|
|
||||||
constructor(private readonly leadsService: LeadsService) {}
|
constructor(private readonly leadsService: LeadsService) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
this.systemPromptCache = this.cargarPrompts([
|
this.systemPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_flujo.md', 'luisa_casos.md']);
|
||||||
'luisa_core.md',
|
|
||||||
'luisa_flujo.md',
|
|
||||||
'luisa_casos.md',
|
|
||||||
]);
|
|
||||||
this.reglasPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_casos.md']);
|
this.reglasPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_casos.md']);
|
||||||
this.logger.log(
|
this.logger.log(`Prompts cargados: system=${this.systemPromptCache.length} chars, reglas=${this.reglasPromptCache.length} chars`);
|
||||||
`Prompts cargados: system=${this.systemPromptCache.length} chars, reglas=${this.reglasPromptCache.length} chars`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private cargarPrompts(archivos: string[]): string {
|
private cargarPrompts(archivos: string[]): string {
|
||||||
const partes: string[] = [];
|
const partes: string[] = [];
|
||||||
|
|
||||||
for (const archivo of archivos) {
|
for (const archivo of archivos) {
|
||||||
const rutaCompleta = path.join(this.promptsDir, archivo);
|
const rutaCompleta = path.join(this.promptsDir, archivo);
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(rutaCompleta)) {
|
if (!fs.existsSync(rutaCompleta)) { this.logger.warn(`Prompt no encontrado: ${archivo}`); continue; }
|
||||||
this.logger.warn(`Prompt no encontrado: ${archivo}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const contenido = fs.readFileSync(rutaCompleta, 'utf-8');
|
const contenido = fs.readFileSync(rutaCompleta, 'utf-8');
|
||||||
if (contenido.trim()) {
|
if (contenido.trim()) partes.push(`\n\n## ${archivo}\n${contenido}`);
|
||||||
partes.push(`\n\n## ${archivo}\n${contenido}`);
|
} catch { this.logger.warn(`No se pudo leer el prompt: ${archivo}`); }
|
||||||
}
|
}
|
||||||
} catch {
|
return partes.join('\n').trim() || DEFAULT_SYSTEM_PROMPT;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getModelo(clave: 'clasificador' | 'generador' | 'reglas'): string {
|
private getModelo(clave: 'clasificador' | 'generador' | 'reglas'): string {
|
||||||
const defaults = {
|
const envMap: Record<string, string | undefined> = {
|
||||||
clasificador: 'anthropic/claude-haiku-4-5',
|
|
||||||
generador: 'anthropic/claude-sonnet-4-5',
|
|
||||||
reglas: 'anthropic/claude-haiku-4-5',
|
|
||||||
};
|
|
||||||
|
|
||||||
const envMap = {
|
|
||||||
clasificador: process.env.MODEL_CLASIFICADOR,
|
clasificador: process.env.MODEL_CLASIFICADOR,
|
||||||
generador: process.env.MODEL_GENERADOR || process.env.MODEL,
|
generador: process.env.MODEL_GENERADOR || process.env.MODEL,
|
||||||
reglas: process.env.MODEL_REGLAS || process.env.MODEL_CLASIFICADOR,
|
reglas: process.env.MODEL_REGLAS || process.env.MODEL_CLASIFICADOR,
|
||||||
};
|
};
|
||||||
|
return envMap[clave] || (clave === 'generador' ? 'anthropic/claude-sonnet-4-5' : 'anthropic/claude-haiku-4-5');
|
||||||
return envMap[clave] || defaults[clave];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializarLead(lead: Lead): string {
|
private serializarLead(lead: LeadBasico): string {
|
||||||
return [
|
return [
|
||||||
`- ID: ${lead.id}`,
|
`- ID: ${lead.id}`,
|
||||||
`- Telefono: ${lead.telefono}`,
|
`- Telefono: ${lead.telefono}`,
|
||||||
@@ -129,10 +108,6 @@ export class ClaudeService implements OnModuleInit {
|
|||||||
].join('\n');
|
].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(
|
private async llamarOpenRouter(
|
||||||
model: string,
|
model: string,
|
||||||
system: string,
|
system: string,
|
||||||
@@ -140,95 +115,47 @@ export class ClaudeService implements OnModuleInit {
|
|||||||
options: { temperature?: number; jsonMode?: boolean } = {},
|
options: { temperature?: number; jsonMode?: boolean } = {},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { temperature = 0.7, jsonMode = false } = options;
|
const { temperature = 0.7, jsonMode = false } = options;
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
model,
|
model,
|
||||||
messages: [{ role: 'system', content: system }, ...messages],
|
messages: [{ role: 'system', content: system }, ...messages],
|
||||||
max_tokens: 1024,
|
max_tokens: 1024,
|
||||||
temperature,
|
temperature,
|
||||||
};
|
};
|
||||||
|
if (jsonMode) payload.response_format = { type: 'json_object' };
|
||||||
|
|
||||||
if (jsonMode) {
|
const response = await axios.post('https://openrouter.ai/api/v1/chat/completions', payload, {
|
||||||
payload.response_format = { type: 'json_object' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axios.post(
|
|
||||||
'https://openrouter.ai/api/v1/chat/completions',
|
|
||||||
payload,
|
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'HTTP-Referer': 'https://reformix.es',
|
'HTTP-Referer': 'https://reformix.es',
|
||||||
'X-Title': 'Reformix Luisa Bot',
|
'X-Title': 'Reformix Luisa Bot',
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
return response.data.choices?.[0]?.message?.content || '';
|
||||||
|
|
||||||
const contenido = response.data.choices?.[0]?.message?.content || '';
|
|
||||||
const modeloUsado = response.data.model || model;
|
|
||||||
|
|
||||||
if (!contenido.trim()) {
|
|
||||||
this.logger.warn(
|
|
||||||
`OpenRouter devolvio contenido vacio (modelo=${modeloUsado})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return contenido;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private parsearJson<T>(texto: string): T | null {
|
private parsearJson<T>(texto: string): T | null {
|
||||||
const limpio = texto
|
const limpio = texto.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
|
||||||
.replace(/```json\s*/gi, '')
|
|
||||||
.replace(/```\s*/g, '')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const inicio = limpio.indexOf('{');
|
const inicio = limpio.indexOf('{');
|
||||||
const fin = limpio.lastIndexOf('}');
|
const fin = limpio.lastIndexOf('}');
|
||||||
if (inicio === -1 || fin === -1) return null;
|
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(
|
private normalizarClasificacion(raw: Partial<ClasificacionResultado>): ClasificacionResultado | null {
|
||||||
raw: Partial<ClasificacionResultado>,
|
const intenciones = ['respuesta', 'desvio', 'despedida', 'insulto', 'pregunta'] as const;
|
||||||
): ClasificacionResultado | null {
|
|
||||||
const intenciones = [
|
|
||||||
'respuesta',
|
|
||||||
'desvio',
|
|
||||||
'despedida',
|
|
||||||
'insulto',
|
|
||||||
'pregunta',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
if (!raw || typeof raw.responde_pregunta !== 'boolean') return null;
|
if (!raw || typeof raw.responde_pregunta !== 'boolean') return null;
|
||||||
|
|
||||||
const intencion = intenciones.includes(raw.intencion as typeof intenciones[number])
|
const intencion = intenciones.includes(raw.intencion as typeof intenciones[number])
|
||||||
? (raw.intencion as ClasificacionResultado['intencion'])
|
? (raw.intencion as ClasificacionResultado['intencion']) : 'respuesta';
|
||||||
: 'respuesta';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
responde_pregunta: raw.responde_pregunta,
|
responde_pregunta: raw.responde_pregunta,
|
||||||
valor_extraido:
|
valor_extraido: raw.valor_extraido === null || raw.valor_extraido === undefined ? null : String(raw.valor_extraido),
|
||||||
raw.valor_extraido === null || raw.valor_extraido === undefined
|
|
||||||
? null
|
|
||||||
: String(raw.valor_extraido),
|
|
||||||
es_desvio: Boolean(raw.es_desvio),
|
es_desvio: Boolean(raw.es_desvio),
|
||||||
intencion,
|
intencion,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async clasificar(mensaje: string, estadoActual: string): Promise<ClasificacionResultado> {
|
||||||
* Capa 1 — Clasificador (Haiku): extrae intencion y valor del mensaje.
|
|
||||||
*/
|
|
||||||
private async clasificar(
|
|
||||||
mensaje: string,
|
|
||||||
estadoActual: string,
|
|
||||||
): Promise<ClasificacionResultado> {
|
|
||||||
const valoresPermitidos = this.leadsService.getValoresPermitidos(estadoActual);
|
const valoresPermitidos = this.leadsService.getValoresPermitidos(estadoActual);
|
||||||
const system = `Eres un clasificador de mensajes para un bot de cualificacion de leads de reformas.
|
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.
|
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
|
Valores validos de intencion: respuesta, desvio, despedida, insulto, pregunta
|
||||||
|
|
||||||
Reglas para valor_extraido:
|
Reglas para valor_extraido:
|
||||||
- espacio: cocina, bano, salon, integral, otro
|
- espacio: cocina, bano, salon, comedor, integral, otro
|
||||||
- tamano: menos10, 10a20, 20a40, mas40
|
- tamano: menos10, 10a20, 20a40, mas40
|
||||||
- estilo: funcional, cuidado, exclusivo
|
- 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
|
- presupuesto: numero o rango en euros tal como lo dijo el usuario
|
||||||
- apertura: null si solo confirma disponibilidad; extrae nombre si lo menciona
|
- 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
|
- 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
|
- 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
|
- 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)`;
|
- Extrae el valor semantico aunque venga en lenguaje coloquial`;
|
||||||
|
|
||||||
const intentos = [
|
|
||||||
{ jsonMode: true, temperature: 0.1 },
|
|
||||||
{ jsonMode: true, temperature: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
const intentos = [{ jsonMode: true, temperature: 0.1 }, { jsonMode: true, temperature: 0 }];
|
||||||
for (const opts of intentos) {
|
for (const opts of intentos) {
|
||||||
const contenido = await this.llamarOpenRouter(
|
const contenido = await this.llamarOpenRouter(this.getModelo('clasificador'), system, [{ role: 'user', content: mensaje }], opts);
|
||||||
this.getModelo('clasificador'),
|
const parsed = this.normalizarClasificacion(this.parsearJson<Partial<ClasificacionResultado>>(contenido) ?? {});
|
||||||
system,
|
|
||||||
[{ role: 'user', content: mensaje }],
|
|
||||||
opts,
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsed = this.normalizarClasificacion(
|
|
||||||
this.parsearJson<Partial<ClasificacionResultado>>(contenido) ?? {},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (parsed) return parsed;
|
if (parsed) return parsed;
|
||||||
|
this.logger.warn(`Clasificador JSON invalido (intento): ${contenido.slice(0, 200)}`);
|
||||||
this.logger.warn(
|
}
|
||||||
`Clasificador JSON invalido (intento, modelo=${this.getModelo('clasificador')}): ${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');
|
private validar(clasificacion: ClasificacionResultado, estadoActual: string): ValidacionResultado {
|
||||||
return {
|
|
||||||
responde_pregunta: false,
|
|
||||||
valor_extraido: null,
|
|
||||||
es_desvio: true,
|
|
||||||
intencion: 'desvio',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Capa 2 — Validador en codigo: valida valor_extraido contra valores permitidos.
|
|
||||||
*/
|
|
||||||
private validar(
|
|
||||||
clasificacion: ClasificacionResultado,
|
|
||||||
estadoActual: string,
|
|
||||||
): ValidacionResultado {
|
|
||||||
const estado = this.leadsService.normalizarEstadoFlujo(estadoActual);
|
const estado = this.leadsService.normalizarEstadoFlujo(estadoActual);
|
||||||
|
if (clasificacion.es_desvio || clasificacion.intencion === 'desvio' || clasificacion.intencion === 'pregunta' ||
|
||||||
if (
|
clasificacion.intencion === 'insulto' || clasificacion.intencion === 'despedida') {
|
||||||
clasificacion.es_desvio ||
|
|
||||||
clasificacion.intencion === 'desvio' ||
|
|
||||||
clasificacion.intencion === 'pregunta' ||
|
|
||||||
clasificacion.intencion === 'insulto' ||
|
|
||||||
clasificacion.intencion === 'despedida'
|
|
||||||
) {
|
|
||||||
return { valido: false, valorNormalizado: null };
|
return { valido: false, valorNormalizado: null };
|
||||||
}
|
}
|
||||||
|
if (estado === 'nuevo') return { valido: false, valorNormalizado: null };
|
||||||
if (estado === 'nuevo') {
|
|
||||||
return { valido: false, valorNormalizado: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (estado === 'apertura') {
|
if (estado === 'apertura') {
|
||||||
const valido =
|
return { valido: clasificacion.responde_pregunta && clasificacion.intencion === 'respuesta' && !clasificacion.es_desvio, valorNormalizado: clasificacion.valor_extraido };
|
||||||
clasificacion.responde_pregunta &&
|
|
||||||
clasificacion.intencion === 'respuesta' &&
|
|
||||||
!clasificacion.es_desvio;
|
|
||||||
return { valido, valorNormalizado: clasificacion.valor_extraido };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (estado === 'presupuesto') {
|
if (estado === 'presupuesto') {
|
||||||
const valor = clasificacion.valor_extraido?.trim();
|
const valor = clasificacion.valor_extraido?.trim();
|
||||||
if (!valor || !this.leadsService.esPresupuestoValido(valor)) {
|
if (!valor || !this.leadsService.esPresupuestoValido(valor)) return { valido: false, valorNormalizado: null };
|
||||||
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 valoresPermitidos = this.leadsService.getValoresPermitidos(estado);
|
||||||
const valor = this.normalizarTexto(clasificacion.valor_extraido ?? '');
|
const valor = this.normalizarTexto(clasificacion.valor_extraido ?? '');
|
||||||
|
if (!valor) return { valido: false, valorNormalizado: null };
|
||||||
if (!valor) {
|
const coincide = valoresPermitidos.some((v) => v === valor || valor.includes(v) || v.includes(valor));
|
||||||
return { valido: false, valorNormalizado: null };
|
if (!coincide) return { valido: false, valorNormalizado: null };
|
||||||
}
|
const valorNormalizado = valoresPermitidos.find((v) => v === valor || valor.includes(v) || v.includes(valor)) ?? valor;
|
||||||
|
|
||||||
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 };
|
return { valido: true, valorNormalizado };
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizarTexto(valor: string): string {
|
private normalizarTexto(valor: string): string {
|
||||||
return valor
|
return valor.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/\p{Diacritic}/gu, '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private claveReintento(leadId: number, estado: string): string {
|
private claveReintento(leadId: string, estado: string): string { return `${leadId}:${estado}`; }
|
||||||
return `${leadId}:${estado}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private obtenerReintentos(leadId: number, estado: string): number {
|
private obtenerReintentos(leadId: string, estado: string): number {
|
||||||
const clave = this.claveReintento(leadId, estado);
|
const entry = this.reintentosPorLead.get(this.claveReintento(leadId, estado));
|
||||||
const entry = this.reintentosPorLead.get(clave);
|
|
||||||
return entry?.estado === estado ? entry.count : 0;
|
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 clave = this.claveReintento(leadId, estado);
|
||||||
const actual = this.obtenerReintentos(leadId, estado);
|
const actual = this.obtenerReintentos(leadId, estado);
|
||||||
const count = actual + 1;
|
const count = actual + 1;
|
||||||
@@ -380,15 +238,12 @@ Reglas para valor_extraido:
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetearReintentos(leadId: number, estado: string): void {
|
private resetearReintentos(leadId: string, estado: string): void {
|
||||||
this.reintentosPorLead.delete(this.claveReintento(leadId, estado));
|
this.reintentosPorLead.delete(this.claveReintento(leadId, estado));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Capa 3 — Generador (Sonnet): produce el borrador del mensaje de Luisa.
|
|
||||||
*/
|
|
||||||
private async generar(
|
private async generar(
|
||||||
lead: Lead,
|
lead: LeadBasico,
|
||||||
historial: Array<{ role: string; content: string }>,
|
historial: Array<{ role: string; content: string }>,
|
||||||
mensajeActual: string,
|
mensajeActual: string,
|
||||||
clasificacion: ClasificacionResultado,
|
clasificacion: ClasificacionResultado,
|
||||||
@@ -398,11 +253,7 @@ Reglas para valor_extraido:
|
|||||||
siguienteEstado: string | null,
|
siguienteEstado: string | null,
|
||||||
forzarApertura = false,
|
forzarApertura = false,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const systemPrompt = this.leerPromptsSistema();
|
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
|
||||||
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(
|
|
||||||
lead.estado_actual,
|
|
||||||
);
|
|
||||||
|
|
||||||
const contextoGeneracion = `
|
const contextoGeneracion = `
|
||||||
## Contexto del lead
|
## Contexto del lead
|
||||||
${this.serializarLead(lead)}
|
${this.serializarLead(lead)}
|
||||||
@@ -419,11 +270,10 @@ ${this.serializarLead(lead)}
|
|||||||
|
|
||||||
## Instrucciones de respuesta
|
## Instrucciones de respuesta
|
||||||
Eres Luisa de Reformix en Madrid. Sigue el system prompt al pie de la letra.
|
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.
|
Devuelve SOLO el texto del mensaje a enviar por WhatsApp.
|
||||||
NO incluyas JSON, etiquetas XML ni bloques de datos extraidos.
|
NO incluyas JSON, etiquetas XML ni bloques de datos extraidos.
|
||||||
NUNCA digas que eres IA, OpenAI, ChatGPT ni un asistente virtual.
|
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 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.
|
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 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.`;
|
Si el siguiente estado es fin_viable o fin_no_viable, usa el mensaje de cierre correspondiente.`;
|
||||||
|
|
||||||
const messages = [
|
const contenido = await this.llamarOpenRouter(this.getModelo('generador'),
|
||||||
...historial,
|
`${this.systemPromptCache || DEFAULT_SYSTEM_PROMPT}\n${contextoGeneracion}`,
|
||||||
{ role: 'user', content: mensajeActual },
|
[...historial, { role: 'user', content: mensajeActual }],
|
||||||
];
|
|
||||||
|
|
||||||
const contenido = await this.llamarOpenRouter(
|
|
||||||
this.getModelo('generador'),
|
|
||||||
`${systemPrompt}\n${contextoGeneracion}`,
|
|
||||||
messages,
|
|
||||||
{ temperature: 0.7 },
|
{ temperature: 0.7 },
|
||||||
);
|
);
|
||||||
|
|
||||||
return contenido.trim();
|
return contenido.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async aplicarReglas(borrador: string, lead: LeadBasico, estadoFlujo: string, clasificacion: ClasificacionResultado): Promise<string> {
|
||||||
* Capa 4 — Reglas (Haiku): corrige el borrador para cumplir identidad y tono de Luisa.
|
const reglas = this.reglasPromptCache || DEFAULT_SYSTEM_PROMPT;
|
||||||
*/
|
|
||||||
private async aplicarReglas(
|
|
||||||
borrador: string,
|
|
||||||
lead: Lead,
|
|
||||||
estadoFlujo: string,
|
|
||||||
clasificacion: ClasificacionResultado,
|
|
||||||
): Promise<string> {
|
|
||||||
const reglas = this.leerPromptsReglas();
|
|
||||||
|
|
||||||
const system = `${reglas}
|
const system = `${reglas}
|
||||||
|
|
||||||
## Tu tarea
|
## Tu tarea
|
||||||
@@ -471,19 +305,16 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
|||||||
|
|
||||||
## Reglas de correccion obligatorias
|
## Reglas de correccion obligatorias
|
||||||
- Debe sonar como Luisa de Reformix (Madrid), nunca como un asistente generico
|
- 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
|
- Maximo 2 lineas, un mensaje por turno, sin emojis, sin guiones largos
|
||||||
- NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales
|
- NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales
|
||||||
- Si preguntan el nombre: "Soy Luisa de Reformix"
|
- Si preguntan el nombre: "Soy Luisa de Reformix"
|
||||||
- Si el borrador viola alguna regla, reescribelo completamente manteniendo la intencion`;
|
- Si el borrador viola alguna regla, reescribelo completamente manteniendo la intencion`;
|
||||||
|
|
||||||
const contenido = await this.llamarOpenRouter(
|
const contenido = await this.llamarOpenRouter(this.getModelo('reglas'), system,
|
||||||
this.getModelo('reglas'),
|
|
||||||
system,
|
|
||||||
[{ role: 'user', content: `Borrador a corregir:\n${borrador}` }],
|
[{ role: 'user', content: `Borrador a corregir:\n${borrador}` }],
|
||||||
{ temperature: 0.3 },
|
{ temperature: 0.3 },
|
||||||
);
|
);
|
||||||
|
|
||||||
return contenido.trim() || borrador;
|
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));
|
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 nombre = lead.nombre ? lead.nombre : '';
|
||||||
const fallbacks: Record<string, string> = {
|
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?`,
|
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?',
|
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?',
|
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(
|
async llamarClaude(
|
||||||
lead: Lead,
|
lead: LeadBasico,
|
||||||
historial: Array<{ role: string; content: string }>,
|
historial: Array<{ role: string; content: string }>,
|
||||||
mensajeActual: string,
|
mensajeActual: string,
|
||||||
): Promise<ClaudeResponse> {
|
): Promise<ClaudeResponse> {
|
||||||
const esAperturaScheduler =
|
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (estadoFlujo === 'nuevo') {
|
if (estadoFlujo === 'nuevo') {
|
||||||
const clasificacion: ClasificacionResultado = {
|
const clasificacion: ClasificacionResultado = { responde_pregunta: false, valor_extraido: null, es_desvio: false, intencion: 'respuesta' };
|
||||||
responde_pregunta: false,
|
const borrador = await this.generar(lead, historial, mensajeActual, clasificacion,
|
||||||
valor_extraido: null,
|
{ valido: false, valorNormalizado: null }, 0, false, null, true);
|
||||||
es_desvio: false,
|
const respuesta = await this.aplicarReglas(borrador, lead, 'nuevo', clasificacion);
|
||||||
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 {
|
return {
|
||||||
respuesta: this.contieneFraseProhibida(respuesta)
|
respuesta: this.contieneFraseProhibida(respuesta) ? this.mensajeFallback('nuevo', lead) : respuesta,
|
||||||
? this.mensajeFallback('nuevo', lead)
|
|
||||||
: respuesta,
|
|
||||||
nuevoEstado: 'apertura',
|
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 reintentos = this.obtenerReintentos(lead.id, estadoFlujo);
|
||||||
let avanzarEstado = false;
|
let avanzarEstado = false;
|
||||||
let siguienteEstado: string | null = null;
|
let siguienteEstado: string | null = null;
|
||||||
let entidad: Partial<Lead> = {};
|
let entidad: Partial<LeadBasico> = {};
|
||||||
let viable: boolean | undefined;
|
let viable: boolean | undefined;
|
||||||
|
|
||||||
const puedeAvanzar =
|
const puedeAvanzar = validacion.valido && !clasificacion.es_desvio && clasificacion.intencion === 'respuesta';
|
||||||
validacion.valido &&
|
|
||||||
!clasificacion.es_desvio &&
|
|
||||||
clasificacion.intencion === 'respuesta';
|
|
||||||
|
|
||||||
if (puedeAvanzar) {
|
if (puedeAvanzar) {
|
||||||
avanzarEstado = true;
|
avanzarEstado = true;
|
||||||
this.resetearReintentos(lead.id, estadoFlujo);
|
this.resetearReintentos(lead.id, estadoFlujo);
|
||||||
|
|
||||||
if (validacion.valorNormalizado) {
|
if (validacion.valorNormalizado) {
|
||||||
const campo = this.leadsService.getCampoParaEstado(estadoFlujo);
|
const campo = this.leadsService.getCampoParaEstado(estadoFlujo);
|
||||||
if (campo) {
|
if (campo) {
|
||||||
entidad = { [campo]: validacion.valorNormalizado };
|
(entidad as any)[campo] = validacion.valorNormalizado;
|
||||||
} else if (
|
} else if (estadoFlujo === 'apertura' && clasificacion.valor_extraido?.trim()) {
|
||||||
estadoFlujo === 'apertura' &&
|
entidad.nombre = clasificacion.valor_extraido.trim();
|
||||||
clasificacion.valor_extraido?.trim()
|
|
||||||
) {
|
|
||||||
entidad = { nombre: clasificacion.valor_extraido.trim() };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (estadoFlujo === 'presupuesto') {
|
if (estadoFlujo === 'presupuesto') {
|
||||||
viable = validacion.viable;
|
viable = validacion.viable;
|
||||||
siguienteEstado = this.leadsService.getSiguienteEstado(
|
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo, viable);
|
||||||
estadoFlujo,
|
|
||||||
viable,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo);
|
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo);
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (!validacion.valido && clasificacion.responde_pregunta && !clasificacion.es_desvio) {
|
||||||
!validacion.valido &&
|
|
||||||
clasificacion.responde_pregunta &&
|
|
||||||
!clasificacion.es_desvio
|
|
||||||
) {
|
|
||||||
reintentos = this.incrementarReintentos(lead.id, estadoFlujo);
|
reintentos = this.incrementarReintentos(lead.id, estadoFlujo);
|
||||||
if (reintentos > 2) {
|
if (reintentos > 2) reintentos = 2;
|
||||||
reintentos = 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const borrador = await this.generar(
|
const borrador = await this.generar(lead, historial, mensajeActual, clasificacion, validacion, reintentos, avanzarEstado, siguienteEstado);
|
||||||
lead,
|
let respuesta = await this.aplicarReglas(borrador, lead, estadoFlujo, clasificacion);
|
||||||
historial,
|
|
||||||
mensajeActual,
|
|
||||||
clasificacion,
|
|
||||||
validacion,
|
|
||||||
reintentos,
|
|
||||||
avanzarEstado,
|
|
||||||
siguienteEstado,
|
|
||||||
);
|
|
||||||
|
|
||||||
let respuesta = await this.aplicarReglas(
|
|
||||||
borrador,
|
|
||||||
lead,
|
|
||||||
estadoFlujo,
|
|
||||||
clasificacion,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.contieneFraseProhibida(respuesta)) {
|
if (this.contieneFraseProhibida(respuesta)) {
|
||||||
this.logger.warn(
|
this.logger.warn(`Respuesta final viola reglas, usando fallback para estado=${estadoFlujo}`);
|
||||||
`Respuesta final viola reglas de identidad, usando fallback para estado=${estadoFlujo}`,
|
|
||||||
);
|
|
||||||
respuesta = this.mensajeFallback(estadoFlujo, lead);
|
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 { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { Conversacion } from './conversacion.entity';
|
|
||||||
import { ConversacionService } from './conversacion.service';
|
import { ConversacionService } from './conversacion.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Conversacion])],
|
|
||||||
providers: [ConversacionService],
|
providers: [ConversacionService],
|
||||||
exports: [ConversacionService],
|
exports: [ConversacionService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,41 +1,26 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { ApiClient } from '../api/api-client.service';
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { Conversacion, RolMensaje } from './conversacion.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ConversacionService {
|
export class ConversacionService {
|
||||||
constructor(
|
private readonly logger = new Logger(ConversacionService.name);
|
||||||
@InjectRepository(Conversacion)
|
|
||||||
private readonly convRepo: Repository<Conversacion>,
|
constructor(private readonly api: ApiClient) {}
|
||||||
) {}
|
|
||||||
|
|
||||||
async guardarMensaje(
|
async guardarMensaje(
|
||||||
leadId: number,
|
leadId: string,
|
||||||
rol: RolMensaje,
|
rol: 'user' | 'assistant' | 'system',
|
||||||
mensaje: string,
|
mensaje: string,
|
||||||
): Promise<Conversacion> {
|
options?: { estadoWa?: string; botStep?: string },
|
||||||
const entry = this.convRepo.create({ lead_id: leadId, rol, mensaje });
|
): Promise<boolean> {
|
||||||
return this.convRepo.save(entry);
|
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[]> {
|
async obtenerHistorialComoMessages(leadId: string): Promise<Array<{ role: string; content: string }>> {
|
||||||
return this.convRepo.find({
|
return this.api.obtenerHistorial(leadId);
|
||||||
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,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { Lead } from './lead.entity';
|
|
||||||
import { LeadsService } from './leads.service';
|
import { LeadsService } from './leads.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Lead])],
|
|
||||||
providers: [LeadsService],
|
providers: [LeadsService],
|
||||||
exports: [LeadsService],
|
exports: [LeadsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { ApiClient } from '../api/api-client.service';
|
||||||
import { Repository, LessThan } from 'typeorm';
|
|
||||||
import { Lead, EstadoLead } from './lead.entity';
|
|
||||||
|
|
||||||
const SECUENCIA_ESTADOS = [
|
const SECUENCIA_ESTADOS = [
|
||||||
'nuevo',
|
'nuevo',
|
||||||
@@ -14,200 +12,83 @@ const SECUENCIA_ESTADOS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const VALORES_POR_ESTADO: Record<string, string[]> = {
|
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'],
|
tamano: ['menos10', '10a20', '20a40', 'mas40'],
|
||||||
estilo: ['funcional', 'cuidado', 'exclusivo'],
|
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',
|
espacio: 'espacio',
|
||||||
tamano: 'rango_m2',
|
tamano: 'rangoM2',
|
||||||
estilo: 'estilo',
|
estilo: 'estilo',
|
||||||
urgencia: 'urgencia',
|
urgencia: 'urgencia',
|
||||||
presupuesto: 'presupuesto_declarado',
|
presupuesto: 'presupuestoDeclarado',
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LeadsService {
|
export class LeadsService {
|
||||||
private readonly logger = new Logger(LeadsService.name);
|
private readonly logger = new Logger(LeadsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly api: ApiClient) {}
|
||||||
@InjectRepository(Lead)
|
|
||||||
private readonly leadRepo: Repository<Lead>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normaliza estados legacy del scheduler/DB al flujo de cualificacion.
|
|
||||||
*/
|
|
||||||
normalizarEstadoFlujo(estado: string): string {
|
normalizarEstadoFlujo(estado: string): string {
|
||||||
if (estado === 'en_proceso' || estado === 'recopilando_datos') {
|
if (estado === 'en_proceso' || estado === 'recopilando_datos') return 'apertura';
|
||||||
return 'apertura';
|
|
||||||
}
|
|
||||||
return estado;
|
return estado;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSiguienteEstado(estadoActual: string, viable?: boolean): string {
|
getSiguienteEstado(estadoActual: string, viable?: boolean): string {
|
||||||
const estado = this.normalizarEstadoFlujo(estadoActual);
|
const estado = this.normalizarEstadoFlujo(estadoActual);
|
||||||
|
if (estado === 'presupuesto') return viable === false ? 'fin_no_viable' : 'fin_viable';
|
||||||
if (estado === 'presupuesto') {
|
const idx = SECUENCIA_ESTADOS.indexOf(estado as typeof SECUENCIA_ESTADOS[number]);
|
||||||
return viable === false ? 'fin_no_viable' : 'fin_viable';
|
if (idx === -1 || idx >= SECUENCIA_ESTADOS.length - 1) return estado;
|
||||||
}
|
|
||||||
|
|
||||||
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];
|
return SECUENCIA_ESTADOS[idx + 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
getValoresPermitidos(estado: string): string[] {
|
getValoresPermitidos(estado: string): string[] {
|
||||||
const estadoNorm = this.normalizarEstadoFlujo(estado);
|
return VALORES_POR_ESTADO[this.normalizarEstadoFlujo(estado)] ?? [];
|
||||||
return VALORES_POR_ESTADO[estadoNorm] ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCampoParaEstado(estado: string): keyof Lead | null {
|
getCampoParaEstado(estado: string): string | null {
|
||||||
const estadoNorm = this.normalizarEstadoFlujo(estado);
|
return CAMPO_POR_ESTADO_NOMBRE[this.normalizarEstadoFlujo(estado)] ?? null;
|
||||||
return CAMPO_POR_ESTADO[estadoNorm] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
esPresupuestoValido(valor: string): boolean {
|
esPresupuestoValido(valor: string): boolean {
|
||||||
const normalizado = valor.trim().toLowerCase();
|
return /\d/.test(valor.trim().toLowerCase());
|
||||||
if (!normalizado) return false;
|
|
||||||
return /\d/.test(normalizado);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluarViabilidad(presupuesto: string): boolean {
|
evaluarViabilidad(presupuesto: string): boolean {
|
||||||
const numeros = presupuesto.match(/\d[\d.]*/g);
|
const numeros = presupuesto.match(/\d[\d.]*/g);
|
||||||
if (!numeros?.length) return true;
|
if (!numeros?.length) return true;
|
||||||
|
|
||||||
const valor = parseInt(numeros[0].replace(/\./g, ''), 10);
|
const valor = parseInt(numeros[0].replace(/\./g, ''), 10);
|
||||||
if (Number.isNaN(valor)) return true;
|
if (Number.isNaN(valor)) return true;
|
||||||
|
|
||||||
return valor >= 5000;
|
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(
|
async persistirTurno(
|
||||||
leadId: number,
|
leadId: string,
|
||||||
datos: Partial<Lead>,
|
datos: Record<string, unknown>,
|
||||||
options?: { nuevoEstado?: string; viable?: boolean },
|
options?: { nuevoEstado?: string; viable?: boolean },
|
||||||
): Promise<Lead> {
|
): Promise<boolean> {
|
||||||
const patch: Partial<Lead> = { ...datos };
|
const perfil: Record<string, unknown> = { ...datos };
|
||||||
|
|
||||||
if (options?.nuevoEstado === 'fin_viable') {
|
if (options?.nuevoEstado === 'fin_viable') {
|
||||||
patch.viable = true;
|
perfil.viable = true;
|
||||||
patch.estado_actual = 'completado';
|
perfil.botStep = 'presupuesto';
|
||||||
} else if (options?.nuevoEstado === 'fin_no_viable') {
|
} else if (options?.nuevoEstado === 'fin_no_viable') {
|
||||||
patch.viable = false;
|
perfil.viable = false;
|
||||||
patch.estado_actual = 'no_viable';
|
perfil.botStep = 'presupuesto';
|
||||||
} else if (options?.nuevoEstado) {
|
} else if (options?.nuevoEstado) {
|
||||||
patch.estado_actual = options.nuevoEstado as EstadoLead;
|
perfil.botStep = options.nuevoEstado;
|
||||||
} else if (options?.viable !== undefined && options?.viable !== null) {
|
} else if (options?.viable !== undefined) {
|
||||||
patch.viable = options.viable;
|
perfil.viable = options.viable;
|
||||||
patch.estado_actual = options.viable ? 'completado' : 'no_viable';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const campos = Object.keys(patch).filter(
|
const campos = Object.keys(perfil).filter((k) => perfil[k] !== undefined);
|
||||||
(k) => patch[k as keyof Lead] !== undefined,
|
if (campos.length === 0) return true;
|
||||||
);
|
|
||||||
if (campos.length === 0) {
|
|
||||||
return this.leadRepo.findOne({ where: { id: leadId } });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.leadRepo.update(leadId, patch);
|
const ok = await this.api.actualizarPerfil(leadId, perfil);
|
||||||
this.logger.log(
|
this.logger.log(`Lead ${leadId} persistido via API: ${JSON.stringify(perfil)} → ${ok ? 'ok' : 'fallo'}`);
|
||||||
`Lead id=${leadId} persistido: ${JSON.stringify(patch)}`,
|
return ok;
|
||||||
);
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,268 +1,99 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import axios from "axios";
|
import axios from 'axios';
|
||||||
import { EstadoLead } from "../leads/lead.entity";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaService {
|
export class MediaService {
|
||||||
private readonly logger = new Logger(MediaService.name);
|
private readonly logger = new Logger(MediaService.name);
|
||||||
|
private readonly OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||||
private readonly OPENROUTER_URL =
|
|
||||||
"https://openrouter.ai/api/v1/chat/completions";
|
|
||||||
|
|
||||||
private get headers() {
|
private get headers() {
|
||||||
return {
|
return {
|
||||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
"HTTP-Referer": "https://reformix.es",
|
'HTTP-Referer': 'https://reformix.es',
|
||||||
"X-Title": "Reformix Luisa Bot",
|
'X-Title': 'Reformix Luisa Bot',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getModeloTranscripcion(): string {
|
private getModeloTranscripcion(): string {
|
||||||
return (
|
return process.env.MODEL_TRANSCRIPCION || 'google/gemini-2.5-flash';
|
||||||
process.env.MODEL_TRANSCRIPCION || "google/gemini-2.5-flash"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convierte mimetype de WhatsApp al formato que espera OpenRouter input_audio.
|
|
||||||
*/
|
|
||||||
mimeToAudioFormat(mimeType: string): string {
|
mimeToAudioFormat(mimeType: string): string {
|
||||||
const base = mimeType.toLowerCase().split(";")[0].trim();
|
const base = mimeType.toLowerCase().split(';')[0].trim();
|
||||||
const map: Record<string, string> = {
|
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' };
|
||||||
"audio/ogg": "ogg",
|
return map[base] ?? '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 {
|
limpiarTranscripcion(texto: string): string {
|
||||||
return texto
|
return texto.replace(/^#+\s*transcripci[oó]n\s*:?\s*\n?/gim, '')
|
||||||
.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(/^transcripci[oó]n\s*:?\s*\n?/gim, '')
|
||||||
.replace(/^transcripci[oó]n\s*:?\s*\n?/gim, "")
|
.replace(/^```[\s\S]*?\n/g, '').replace(/\n```$/g, '')
|
||||||
.replace(/^```[\s\S]*?\n/g, "")
|
.replace(/^["']|["']$/g, '').trim();
|
||||||
.replace(/\n```$/g, "")
|
|
||||||
.replace(/^["']|["']$/g, "")
|
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectarFormatoPorMagicBytes(buffer: Buffer): string | null {
|
private detectarFormatoPorMagicBytes(buffer: Buffer): string | null {
|
||||||
if (
|
if (buffer.length >= 4 && buffer.subarray(0, 4).toString('ascii') === 'OggS') return 'ogg';
|
||||||
buffer.length >= 4 &&
|
if (buffer.length >= 3 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) return 'mp3';
|
||||||
buffer.subarray(0, 4).toString("ascii") === "OggS"
|
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WAVE') return 'wav';
|
||||||
) {
|
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async transcribirAudio(audioBuffer: Buffer, mimeType = 'audio/ogg; codecs=opus'): Promise<string> {
|
||||||
* Transcribe un audio via OpenRouter input_audio (Gemini por defecto).
|
const FALLBACK = 'No te he oido bien, me lo repites?';
|
||||||
* 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?";
|
|
||||||
|
|
||||||
const formatFromMime = this.mimeToAudioFormat(mimeType);
|
const formatFromMime = this.mimeToAudioFormat(mimeType);
|
||||||
const formatFromMagic = this.detectarFormatoPorMagicBytes(audioBuffer);
|
const formatFromMagic = this.detectarFormatoPorMagicBytes(audioBuffer);
|
||||||
const format = formatFromMagic ?? formatFromMime;
|
const format = formatFromMagic ?? formatFromMime;
|
||||||
const base64Audio = audioBuffer.toString("base64");
|
const base64Audio = audioBuffer.toString('base64');
|
||||||
const model = this.getModeloTranscripcion();
|
const model = this.getModeloTranscripcion();
|
||||||
|
|
||||||
this.logger.log(
|
if (audioBuffer.length < 100) return FALLBACK;
|
||||||
`[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) {
|
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.';
|
||||||
this.logger.warn(
|
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.';
|
||||||
`[AUDIO 2/4] Buffer demasiado pequeno (${audioBuffer.length} bytes), abortando transcripcion`,
|
|
||||||
);
|
|
||||||
return FALLBACK;
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemPrompt =
|
|
||||||
"Eres un transcriptor de voz para usuarios de Madrid y Espana. " +
|
|
||||||
"Transcribe en espanol peninsular tal como se habla, conservando coloquialismos, " +
|
|
||||||
"muletillas y jerga (vale, tio, guay, mola, etc.) sin corregir ni formalizar. " +
|
|
||||||
"Responde unicamente con las palabras dichas, sin titulos, markdown, comillas ni explicaciones.";
|
|
||||||
|
|
||||||
const userPrompt =
|
|
||||||
"Transcribe exactamente lo que dice la persona en este audio. " +
|
|
||||||
"Es espanol de Espana, posiblemente con tono coloquial madrileño. " +
|
|
||||||
"Devuelve solo las palabras habladas, tal cual, nada mas.";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const response = await axios.post(this.OPENROUTER_URL, {
|
||||||
model,
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{
|
{ role: 'user', content: [{ type: 'text', text: userPrompt }, { type: 'input_audio', input_audio: { data: base64Audio, format } }] },
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: userPrompt },
|
|
||||||
{
|
|
||||||
type: "input_audio",
|
|
||||||
input_audio: {
|
|
||||||
data: base64Audio,
|
|
||||||
format,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
max_tokens: 512, temperature: 0,
|
||||||
],
|
}, { headers: this.headers });
|
||||||
max_tokens: 512,
|
const raw: string = response.data.choices?.[0]?.message?.content?.trim() ?? '';
|
||||||
temperature: 0,
|
if (!raw) return FALLBACK;
|
||||||
};
|
return this.limpiarTranscripcion(raw) || FALLBACK;
|
||||||
|
} catch (error: any) {
|
||||||
this.logger.debug(
|
this.logger.error(`Error transcribiendo audio: ${error.message}`);
|
||||||
`[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,
|
|
||||||
);
|
|
||||||
return FALLBACK;
|
return FALLBACK;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async inferirImagen(imagenBuffer: Buffer, mimeType = 'image/jpeg', estadoActual = 'en_proceso'): Promise<string> {
|
||||||
* Infiere informacion de una imagen segun el estado actual del lead.
|
const FALLBACK = 'Recibi tu imagen pero no pude analizarla bien. Puedes describirme lo que muestra?';
|
||||||
*/
|
|
||||||
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?";
|
|
||||||
|
|
||||||
const promptPorEstado: Record<string, string> = {
|
const promptPorEstado: Record<string, string> = {
|
||||||
nuevo:
|
nuevo: 'Describe brevemente que tipo de espacio se ve en esta imagen y sus caracteristicas principales.',
|
||||||
"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.',
|
||||||
en_proceso:
|
recopilando_datos: 'Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservacion.',
|
||||||
"Describe el espacio que aparece en la imagen: tipo de habitacion, materiales, estado actual, tamano aproximado.",
|
completado: 'Describe lo que ves en esta imagen relacionado con reformas o diseno de interiores.',
|
||||||
recopilando_datos:
|
no_viable: 'Describe brevemente que muestra esta imagen.',
|
||||||
"Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservacion.",
|
perdido: 'Describe brevemente que muestra esta imagen.',
|
||||||
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 {
|
try {
|
||||||
const base64Imagen = imagenBuffer.toString("base64");
|
const base64Imagen = imagenBuffer.toString('base64');
|
||||||
|
const response = await axios.post(this.OPENROUTER_URL, {
|
||||||
const response = await axios.post(
|
model: process.env.MODEL_GENERADOR || process.env.MODEL || 'anthropic/claude-sonnet-4-5',
|
||||||
this.OPENROUTER_URL,
|
messages: [{ role: 'user', content: [{ type: 'text', text: promptDeVision }, { type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Imagen}` } }] }],
|
||||||
{
|
|
||||||
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,
|
max_tokens: 512,
|
||||||
},
|
}, { headers: this.headers });
|
||||||
{ headers: this.headers },
|
const inferencia: string = response.data.choices?.[0]?.message?.content?.trim();
|
||||||
);
|
return inferencia || FALLBACK;
|
||||||
|
} catch (error: any) {
|
||||||
const inferencia: string =
|
this.logger.error(`Error analizando imagen: ${error.message}`);
|
||||||
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,
|
|
||||||
);
|
|
||||||
return FALLBACK;
|
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 { ConversacionModule } from '../conversacion/conversacion.module';
|
||||||
import { ClaudeModule } from '../claude/claude.module';
|
import { ClaudeModule } from '../claude/claude.module';
|
||||||
import { MediaModule } from '../media/media.module';
|
import { MediaModule } from '../media/media.module';
|
||||||
|
import { WebhookModule } from '../webhook/webhook.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule],
|
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule, WebhookModule],
|
||||||
providers: [WhatsappService, WhatsappDebounceService],
|
providers: [WhatsappService, WhatsappDebounceService],
|
||||||
exports: [WhatsappService],
|
exports: [WhatsappService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
OnModuleDestroy,
|
OnModuleDestroy,
|
||||||
} from "@nestjs/common";
|
} from '@nestjs/common';
|
||||||
import makeWASocket, {
|
import makeWASocket, {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
useMultiFileAuthState,
|
useMultiFileAuthState,
|
||||||
@@ -11,33 +12,42 @@ import makeWASocket, {
|
|||||||
WASocket,
|
WASocket,
|
||||||
downloadMediaMessage,
|
downloadMediaMessage,
|
||||||
normalizeMessageContent,
|
normalizeMessageContent,
|
||||||
} from "@whiskeysockets/baileys";
|
} from '@whiskeysockets/baileys';
|
||||||
import { Boom } from "@hapi/boom";
|
import { Boom } from '@hapi/boom';
|
||||||
import * as path from "path";
|
import * as path from 'path';
|
||||||
const pino = require("pino");
|
const pino = require('pino');
|
||||||
const QRCode = require("qrcode-terminal");
|
const QRCode = require('qrcode-terminal');
|
||||||
import { LeadsService } from "../leads/leads.service";
|
import { LeadsService } from '../leads/leads.service';
|
||||||
import { ConversacionService } from "../conversacion/conversacion.service";
|
import { ConversacionService } from '../conversacion/conversacion.service';
|
||||||
import { ClaudeService } from "../claude/claude.service";
|
import { ClaudeService } from '../claude/claude.service';
|
||||||
import { MediaService } from "../media/media.service";
|
import { MediaService } from '../media/media.service';
|
||||||
import { WhatsappDebounceService } from "./whatsapp-debounce.service";
|
import { WhatsappDebounceService } from './whatsapp-debounce.service';
|
||||||
import { wrapSocket } from "baileys-antiban";
|
import { WebhookListener } from '../webhook/webhook-listener';
|
||||||
|
import { ApiClient } from '../api/api-client.service';
|
||||||
|
import { wrapSocket } from 'baileys-antiban';
|
||||||
|
|
||||||
const ESTADOS_TERMINALES = [
|
export const pdfEmitter = new EventEmitter();
|
||||||
"completado",
|
|
||||||
"no_viable",
|
interface LeadContext {
|
||||||
"perdido",
|
leadId: string;
|
||||||
"fin_viable",
|
telefono: string;
|
||||||
"fin_no_viable",
|
nombre: string;
|
||||||
];
|
botStep: string;
|
||||||
|
viable: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(WhatsappService.name);
|
private readonly logger = new Logger(WhatsappService.name);
|
||||||
private sock: WASocket | null = null;
|
private sock: WASocket | null = null;
|
||||||
private authDir = path.join(process.cwd(), "auth_info_baileys");
|
private authDir = path.join(process.cwd(), 'auth_info_baileys');
|
||||||
private readonly ultimoMsgPorJid = new Map<string, any>();
|
private 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(
|
constructor(
|
||||||
private readonly leadsService: LeadsService,
|
private readonly leadsService: LeadsService,
|
||||||
@@ -45,20 +55,51 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly claudeService: ClaudeService,
|
private readonly claudeService: ClaudeService,
|
||||||
private readonly mediaService: MediaService,
|
private readonly mediaService: MediaService,
|
||||||
private readonly debounceService: WhatsappDebounceService,
|
private readonly debounceService: WhatsappDebounceService,
|
||||||
|
private readonly webhookListener: WebhookListener,
|
||||||
|
private readonly api: ApiClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.conectar();
|
await this.conectar();
|
||||||
|
this.escucharPdf();
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
if (this.sock) {
|
if (this.sock) this.sock.end(undefined);
|
||||||
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 {
|
private normalizarTelefono(jid: string): string {
|
||||||
return jid.split("@")[0].replace(/\D/g, "");
|
return jid.split('@')[0].replace(/\D/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
private calcularDelayEscritura(longitudTexto: number): number {
|
private calcularDelayEscritura(longitudTexto: number): number {
|
||||||
@@ -76,7 +117,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
|
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
|
||||||
const { version } = await fetchLatestBaileysVersion();
|
const { version } = await fetchLatestBaileysVersion();
|
||||||
|
|
||||||
this.baileysLogger = pino({ level: "info" }) as any;
|
this.baileysLogger = pino({ level: 'info' }) as any;
|
||||||
|
|
||||||
this.sock = makeWASocket({
|
this.sock = makeWASocket({
|
||||||
version,
|
version,
|
||||||
@@ -88,56 +129,37 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
|||||||
syncFullHistory: false,
|
syncFullHistory: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sock.ev.on("creds.update", saveCreds);
|
this.sock.ev.on('creds.update', saveCreds);
|
||||||
|
|
||||||
this.sock.ev.on("connection.update", (update) => {
|
this.sock.ev.on('connection.update', (update) => {
|
||||||
const { connection, lastDisconnect, qr } = update;
|
const { connection, lastDisconnect, qr } = update;
|
||||||
|
|
||||||
if (qr) {
|
if (qr) {
|
||||||
QRCode.generate(qr, { small: true });
|
QRCode.generate(qr, { small: true });
|
||||||
console.log("\n📲 Escanea este QR con WhatsApp\n");
|
console.log('\n📲 Escanea este QR con WhatsApp\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connection === "close") {
|
if (connection === 'close') {
|
||||||
const shouldReconnect =
|
const shouldReconnect =
|
||||||
(lastDisconnect?.error as Boom)?.output?.statusCode !==
|
(lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut;
|
||||||
DisconnectReason.loggedOut;
|
this.logger.warn(`Conexion cerrada. Reconectar: ${shouldReconnect}.`);
|
||||||
|
if (shouldReconnect) setTimeout(() => this.conectar(), 5000);
|
||||||
this.logger.warn(
|
else this.logger.error('Sesion cerrada (logged out).');
|
||||||
`Conexion cerrada. Reconectar: ${shouldReconnect}. Razon: ${lastDisconnect?.error?.message}`,
|
} else if (connection === 'open') {
|
||||||
);
|
this.logger.log('✅ WhatsApp conectado. Luisa esta lista.');
|
||||||
|
|
||||||
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.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sock.ev.on("messages.upsert", async ({ messages, type }) => {
|
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||||
if (type !== "notify") return;
|
if (type !== 'notify') return;
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (msg.key.fromMe) continue;
|
if (msg.key.fromMe) continue;
|
||||||
if (!msg.key.remoteJid) continue;
|
if (!msg.key.remoteJid) continue;
|
||||||
if (msg.key.remoteJid.includes("@g.us")) continue;
|
if (msg.key.remoteJid.includes('@g.us')) continue;
|
||||||
|
|
||||||
const telefonoNormalizado = this.normalizarTelefono(msg.key.remoteJid);
|
const telefonoNormalizado = this.normalizarTelefono(msg.key.remoteJid);
|
||||||
const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, "");
|
const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, '');
|
||||||
|
if (allowedNumber && telefonoNormalizado !== allowedNumber) continue;
|
||||||
if (allowedNumber && telefonoNormalizado !== allowedNumber) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Mensaje ignorado: ${telefonoNormalizado} no coincide con ALLOWED_NUMBER`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.encolarMensaje(msg);
|
await this.encolarMensaje(msg);
|
||||||
}
|
}
|
||||||
@@ -147,21 +169,15 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private extraerTextoPlano(msg: any): string | null {
|
private extraerTextoPlano(msg: any): string | null {
|
||||||
const msgContent = msg.message;
|
const msgContent = msg.message;
|
||||||
if (!msgContent) return null;
|
if (!msgContent) return null;
|
||||||
|
|
||||||
if (msgContent.conversation || msgContent.extendedTextMessage) {
|
if (msgContent.conversation || msgContent.extendedTextMessage) {
|
||||||
const texto =
|
const texto = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
|
||||||
msgContent.conversation || msgContent.extendedTextMessage?.text || "";
|
|
||||||
return texto.trim() ? texto : null;
|
return texto.trim() ? texto : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private crearMsgConTexto(msg: any, texto: string): any {
|
private crearMsgConTexto(msg: any, texto: string): any {
|
||||||
return {
|
return { ...msg, message: { conversation: texto } };
|
||||||
...msg,
|
|
||||||
message: { conversation: texto },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async encolarMensaje(msg: any): Promise<void> {
|
private async encolarMensaje(msg: any): Promise<void> {
|
||||||
@@ -174,7 +190,6 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.ultimoMsgPorJid.set(jid, msg);
|
this.ultimoMsgPorJid.set(jid, msg);
|
||||||
|
|
||||||
await this.debounceService.add(jid, textoPlano, async (combinedMessage) => {
|
await this.debounceService.add(jid, textoPlano, async (combinedMessage) => {
|
||||||
const baseMsg = this.ultimoMsgPorJid.get(jid) ?? msg;
|
const baseMsg = this.ultimoMsgPorJid.get(jid) ?? msg;
|
||||||
this.ultimoMsgPorJid.delete(jid);
|
this.ultimoMsgPorJid.delete(jid);
|
||||||
@@ -182,179 +197,184 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async procesarMensaje(msg: any): Promise<void> {
|
private async getOrCreateContext(telefono: string, jid: string): Promise<LeadContext | null> {
|
||||||
const jid = msg.key.remoteJid!;
|
const leadId = this.webhookListener.getLeadIdByTelefono(telefono);
|
||||||
|
|
||||||
if (jid.includes("@g.us")) return;
|
if (!leadId) {
|
||||||
|
this.logger.log(`Mensaje ignorado de ${telefono}: lead no registrado. Debe iniciarse desde la web.`);
|
||||||
const telefono = jid.split("@")[0];
|
return null;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let textoNormalizado = "";
|
this.webhookListener.registerJid(telefono, jid);
|
||||||
const msgContent = normalizeMessageContent(msg.message);
|
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) return;
|
||||||
|
|
||||||
if (msgContent.conversation || msgContent.extendedTextMessage) {
|
if (msgContent.conversation || msgContent.extendedTextMessage) {
|
||||||
textoNormalizado =
|
textoNormalizado = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
|
||||||
msgContent.conversation || msgContent.extendedTextMessage?.text || "";
|
|
||||||
} else if (msgContent.audioMessage) {
|
} else if (msgContent.audioMessage) {
|
||||||
const audioMeta = msgContent.audioMessage;
|
const audioMeta = msgContent.audioMessage;
|
||||||
const mimeType = audioMeta.mimetype || "audio/ogg; codecs=opus";
|
const mimeType = audioMeta.mimetype || 'audio/ogg; codecs=opus';
|
||||||
|
this.logger.log(`[AUDIO] Recibido — lead=${ctx.leadId}`);
|
||||||
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...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!this.sock) return;
|
if (!this.sock) return;
|
||||||
|
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
|
||||||
const buffer = await downloadMediaMessage(
|
|
||||||
msg as any,
|
|
||||||
"buffer",
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
logger: this.baileysLogger,
|
logger: this.baileysLogger,
|
||||||
reuploadRequest: this.sock.updateMediaMessage,
|
reuploadRequest: this.sock.updateMediaMessage,
|
||||||
},
|
});
|
||||||
);
|
const audioBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||||
const mimeType = msgContent.imageMessage.mimetype || "image/jpeg";
|
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(
|
textoNormalizado = await this.mediaService.inferirImagen(
|
||||||
Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer),
|
Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer),
|
||||||
mimeType,
|
mimeType,
|
||||||
lead.estado_actual,
|
'en_proceso',
|
||||||
);
|
);
|
||||||
if (msgContent.imageMessage.caption) {
|
if (msgContent.imageMessage.caption) {
|
||||||
textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`;
|
textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(
|
this.logger.log(`Tipo de mensaje no soportado de lead ${ctx.leadId}. Ignorando.`);
|
||||||
`Tipo de mensaje no soportado de lead id=${lead.id}. Ignorando.`,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!textoNormalizado.trim()) return;
|
if (!textoNormalizado.trim()) return;
|
||||||
|
|
||||||
this.logger.log(`USUARIO [${telefono}]: ${textoNormalizado}`);
|
this.logger.log(`USUARIO [${telefono}]: ${textoNormalizado}`);
|
||||||
|
|
||||||
await this.conversacionService.guardarMensaje(
|
if (primerMensajeDeUsuario) {
|
||||||
lead.id,
|
await this.api.registrarIntento(ctx.leadId, 'whatsapp', 1, 'exitoso', true);
|
||||||
"user",
|
}
|
||||||
textoNormalizado,
|
|
||||||
);
|
|
||||||
|
|
||||||
const historial =
|
if (msgContent.imageMessage) {
|
||||||
await this.conversacionService.obtenerHistorialComoMessages(lead.id);
|
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.conversacionService.guardarMensaje(ctx.leadId, 'user', textoNormalizado, {
|
||||||
await this.claudeService.llamarClaude(
|
botStep: ctx.botStep,
|
||||||
lead,
|
});
|
||||||
|
|
||||||
|
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),
|
historial.slice(0, -1),
|
||||||
textoNormalizado,
|
textoNormalizado,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`LUISA [${telefono}]: ${respuesta}`);
|
this.logger.log(`LUISA [${telefono}]: ${respuesta}`);
|
||||||
|
|
||||||
if (
|
if ((entidad && Object.keys(entidad).length > 0) || nuevoEstado || viable !== undefined) {
|
||||||
(entidad && Object.keys(entidad).length > 0) ||
|
const entidadMap: Record<string, unknown> = {};
|
||||||
nuevoEstado ||
|
if (entidad) {
|
||||||
(viable !== undefined && viable !== null)
|
for (const [k, v] of Object.entries(entidad)) {
|
||||||
) {
|
const mapped = this.mapearCampoALegacy(k);
|
||||||
lead = await this.leadsService.persistirTurno(lead.id, entidad ?? {}, {
|
entidadMap[mapped] = v;
|
||||||
nuevoEstado,
|
}
|
||||||
viable,
|
}
|
||||||
});
|
await this.leadsService.persistirTurno(ctx.leadId, entidadMap, { nuevoEstado, viable });
|
||||||
this.logger.log(
|
if (nuevoEstado) ctx.botStep = nuevoEstado;
|
||||||
`Lead id=${lead.id} en DB — estado=${lead.estado_actual}, espacio=${lead.espacio ?? "-"}, rango_m2=${lead.rango_m2 ?? "-"}, estilo=${lead.estilo ?? "-"}, urgencia=${lead.urgencia ?? "-"}, presupuesto=${lead.presupuesto_declarado ?? "-"}`,
|
if (viable !== undefined) ctx.viable = viable;
|
||||||
);
|
this.logger.log(`Lead ${ctx.leadId} persistido — estado=${nuevoEstado || ctx.botStep}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.conversacionService.guardarMensaje(
|
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', respuesta, {
|
||||||
lead.id,
|
botStep: ctx.botStep,
|
||||||
"assistant",
|
});
|
||||||
respuesta,
|
|
||||||
);
|
|
||||||
await this.enviarMensaje(jid, respuesta);
|
await this.enviarMensaje(jid, respuesta);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(`Error procesando mensaje de ${telefono}: ${error.message}`, error.stack);
|
||||||
`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> {
|
async enviarMensaje(jid: string, texto: string): Promise<void> {
|
||||||
if (!this.sock) return;
|
if (!this.sock) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jidPresencia = jid.includes("@lid")
|
const jidPresencia = jid.includes('@lid')
|
||||||
? `${jid.split("@")[0]}@s.whatsapp.net`
|
? `${jid.split('@')[0]}@s.whatsapp.net`
|
||||||
: jid;
|
: jid;
|
||||||
|
await this.sock.sendPresenceUpdate('composing', jidPresencia);
|
||||||
await this.sock.sendPresenceUpdate("composing", jidPresencia);
|
|
||||||
await this.delay(this.calcularDelayEscritura(texto.length));
|
await this.delay(this.calcularDelayEscritura(texto.length));
|
||||||
await this.sock.sendPresenceUpdate("paused", jidPresencia);
|
await this.sock.sendPresenceUpdate('paused', jidPresencia);
|
||||||
|
|
||||||
const safeSock = wrapSocket(this.sock);
|
const safeSock = wrapSocket(this.sock);
|
||||||
await safeSock.sendMessage(jid, { text: texto });
|
await safeSock.sendMessage(jid, { text: texto });
|
||||||
this.logger.log(`Mensaje enviado a ${jid}`);
|
this.logger.log(`Mensaje enviado a ${jid}`);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Error enviando mensaje a ${jid}: ${error.message}`);
|
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 {
|
isConectado(): boolean {
|
||||||
return this.sock !== null;
|
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": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"ignoreDeprecations": "6.0",
|
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"emitDecoratorMetadata": 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