Logia de agente de Whatsapp
This commit is contained in:
33
mvp/Whatsapp-bot/src/app.module.ts
Normal file
33
mvp/Whatsapp-bot/src/app.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { LeadsModule } from './leads/leads.module';
|
||||
import { ConversacionModule } from './conversacion/conversacion.module';
|
||||
import { WhatsappModule } from './whatsapp/whatsapp.module';
|
||||
import { ClaudeModule } from './claude/claude.module';
|
||||
import { MediaModule } from './media/media.module';
|
||||
import { SchedulerModule } from './scheduler/scheduler.module';
|
||||
import { Lead } from './leads/lead.entity';
|
||||
import { Conversacion } from './conversacion/conversacion.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
url: process.env.DATABASE_URL,
|
||||
entities: [Lead, Conversacion],
|
||||
synchronize: true, // En produccion usar migrations en lugar de synchronize
|
||||
ssl: process.env.DATABASE_URL?.includes('sslmode=require')
|
||||
? { rejectUnauthorized: false }
|
||||
: false,
|
||||
}),
|
||||
LeadsModule,
|
||||
ConversacionModule,
|
||||
WhatsappModule,
|
||||
ClaudeModule,
|
||||
MediaModule,
|
||||
SchedulerModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
mvp/Whatsapp-bot/src/claude/claude.module.ts
Normal file
8
mvp/Whatsapp-bot/src/claude/claude.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClaudeService } from './claude.service';
|
||||
|
||||
@Module({
|
||||
providers: [ClaudeService],
|
||||
exports: [ClaudeService],
|
||||
})
|
||||
export class ClaudeModule {}
|
||||
172
mvp/Whatsapp-bot/src/claude/claude.service.ts
Normal file
172
mvp/Whatsapp-bot/src/claude/claude.service.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import axios from 'axios';
|
||||
import { Lead } from '../leads/lead.entity';
|
||||
import { Conversacion } from '../conversacion/conversacion.entity';
|
||||
|
||||
export interface ClaudeResponse {
|
||||
respuesta: string;
|
||||
entidad?: Partial<Lead>; // datos extraídos del turno
|
||||
viable?: boolean; // flag si Claude decide el resultado final
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClaudeService {
|
||||
private readonly logger = new Logger(ClaudeService.name);
|
||||
private readonly promptsDir = path.join(process.cwd(), 'prompts');
|
||||
|
||||
/**
|
||||
* Lee y concatena los 3 archivos MD de /prompts como system prompt.
|
||||
*/
|
||||
private leerPromptsSistema(): string {
|
||||
const archivos = ['luisa_core.md', 'luisa_flujo.md', 'luisa_casos.md'];
|
||||
const partes: string[] = [];
|
||||
|
||||
for (const archivo of archivos) {
|
||||
const rutaCompleta = path.join(this.promptsDir, archivo);
|
||||
try {
|
||||
const contenido = fs.readFileSync(rutaCompleta, 'utf-8');
|
||||
if (contenido.trim()) {
|
||||
partes.push(`\n\n## ${archivo}\n${contenido}`);
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn(`No se pudo leer el prompt: ${archivo}`);
|
||||
}
|
||||
}
|
||||
|
||||
return partes.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializa los datos actuales del lead para el contexto de Claude.
|
||||
*/
|
||||
private serializarLead(lead: Lead): string {
|
||||
return [
|
||||
`- ID: ${lead.id}`,
|
||||
`- Telefono: ${lead.telefono}`,
|
||||
`- Estado actual: ${lead.estado_actual}`,
|
||||
`- Nombre: ${lead.nombre || 'no capturado'}`,
|
||||
`- Email: ${lead.email || 'no capturado'}`,
|
||||
`- Espacio: ${lead.espacio || 'no capturado'}`,
|
||||
`- Rango m2: ${lead.rango_m2 || 'no capturado'}`,
|
||||
`- Estilo: ${lead.estilo || 'no capturado'}`,
|
||||
`- Urgencia: ${lead.urgencia || 'no capturado'}`,
|
||||
`- Presupuesto declarado: ${lead.presupuesto_declarado || 'no capturado'}`,
|
||||
`- Viable: ${lead.viable !== null && lead.viable !== undefined ? lead.viable : 'pendiente'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Llama a Claude 4.5 via OpenRouter con el contexto completo del lead.
|
||||
* Devuelve la respuesta de Luisa y los datos extraídos del turno.
|
||||
*
|
||||
* @param lead El lead actual con sus datos en DB
|
||||
* @param historial Historial de conversación [{role, content}]
|
||||
* @param mensajeActual El mensaje del usuario (ya puede venir transcrito/inferido)
|
||||
*/
|
||||
async llamarClaude(
|
||||
lead: Lead,
|
||||
historial: Array<{ role: string; content: string }>,
|
||||
mensajeActual: string,
|
||||
): Promise<ClaudeResponse> {
|
||||
const systemPrompt = this.leerPromptsSistema();
|
||||
|
||||
const contextoDeLead = `
|
||||
## Contexto del lead actual
|
||||
|
||||
${this.serializarLead(lead)}
|
||||
`;
|
||||
|
||||
const systemFinal = `${systemPrompt}
|
||||
|
||||
${contextoDeLead}
|
||||
|
||||
## Instrucciones de extracción de datos
|
||||
|
||||
Al responder, incluye al final de tu mensaje un bloque JSON con el formato exacto (sin markdown, sin comillas extras):
|
||||
|
||||
<DATOS_EXTRAIDOS>
|
||||
{
|
||||
"nombre": null,
|
||||
"email": null,
|
||||
"espacio": null,
|
||||
"rango_m2": null,
|
||||
"estilo": null,
|
||||
"urgencia": null,
|
||||
"presupuesto_declarado": null,
|
||||
"viable": null
|
||||
}
|
||||
</DATOS_EXTRAIDOS>
|
||||
|
||||
Solo rellena los campos que has capturado en este turno. Los que no hayas capturado déjalos en null.
|
||||
Si puedes determinar si el lead es viable o no, pon true o false en "viable". Si no, déjalo en null.`;
|
||||
|
||||
const messages = [
|
||||
...historial,
|
||||
{ role: 'user', content: mensajeActual },
|
||||
];
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'https://openrouter.ai/api/v1/chat/completions',
|
||||
{
|
||||
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
|
||||
messages,
|
||||
system: systemFinal,
|
||||
max_tokens: 1024,
|
||||
temperature: 0.7,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Luisa Bot',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const contenidoCompleto: string =
|
||||
response.data.choices?.[0]?.message?.content || '';
|
||||
|
||||
// Separar la respuesta visible del bloque de datos extraídos
|
||||
const regexDatos = /<DATOS_EXTRAIDOS>([\s\S]*?)<\/DATOS_EXTRAIDOS>/;
|
||||
const match = contenidoCompleto.match(regexDatos);
|
||||
|
||||
let respuesta = contenidoCompleto
|
||||
.replace(regexDatos, '')
|
||||
.trim();
|
||||
|
||||
let entidad: Partial<Lead> = {};
|
||||
let viableFlag: boolean | undefined = undefined;
|
||||
|
||||
if (match) {
|
||||
try {
|
||||
const datos = JSON.parse(match[1].trim());
|
||||
// Solo incluir campos no nulos
|
||||
Object.entries(datos).forEach(([k, v]) => {
|
||||
if (v !== null && k !== 'viable') {
|
||||
(entidad as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
});
|
||||
if (datos.viable !== null && datos.viable !== undefined) {
|
||||
viableFlag = datos.viable;
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn('No se pudo parsear DATOS_EXTRAIDOS');
|
||||
}
|
||||
}
|
||||
|
||||
return { respuesta, entidad, viable: viableFlag };
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error llamando a Claude via OpenRouter: ${error.message}`,
|
||||
error.response?.data,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
mvp/Whatsapp-bot/src/conversacion/conversacion.entity.ts
Normal file
33
mvp/Whatsapp-bot/src/conversacion/conversacion.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Lead } from '../leads/lead.entity';
|
||||
|
||||
export type RolMensaje = 'user' | 'assistant' | 'system';
|
||||
|
||||
@Entity('conversacion')
|
||||
export class Conversacion {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
lead_id: number;
|
||||
|
||||
@ManyToOne(() => Lead, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'lead_id' })
|
||||
lead: Lead;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
rol: RolMensaje;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
mensaje: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
}
|
||||
11
mvp/Whatsapp-bot/src/conversacion/conversacion.module.ts
Normal file
11
mvp/Whatsapp-bot/src/conversacion/conversacion.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Conversacion } from './conversacion.entity';
|
||||
import { ConversacionService } from './conversacion.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Conversacion])],
|
||||
providers: [ConversacionService],
|
||||
exports: [ConversacionService],
|
||||
})
|
||||
export class ConversacionModule {}
|
||||
41
mvp/Whatsapp-bot/src/conversacion/conversacion.service.ts
Normal file
41
mvp/Whatsapp-bot/src/conversacion/conversacion.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Conversacion, RolMensaje } from './conversacion.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ConversacionService {
|
||||
constructor(
|
||||
@InjectRepository(Conversacion)
|
||||
private readonly convRepo: Repository<Conversacion>,
|
||||
) {}
|
||||
|
||||
async guardarMensaje(
|
||||
leadId: number,
|
||||
rol: RolMensaje,
|
||||
mensaje: string,
|
||||
): Promise<Conversacion> {
|
||||
const entry = this.convRepo.create({ lead_id: leadId, rol, mensaje });
|
||||
return this.convRepo.save(entry);
|
||||
}
|
||||
|
||||
async obtenerHistorial(leadId: number): Promise<Conversacion[]> {
|
||||
return this.convRepo.find({
|
||||
where: { lead_id: leadId },
|
||||
order: { created_at: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve el historial en formato OpenAI/Claude messages array.
|
||||
*/
|
||||
async obtenerHistorialComoMessages(
|
||||
leadId: number,
|
||||
): Promise<Array<{ role: string; content: string }>> {
|
||||
const historial = await this.obtenerHistorial(leadId);
|
||||
return historial.map((h) => ({
|
||||
role: h.rol,
|
||||
content: h.mensaje,
|
||||
}));
|
||||
}
|
||||
}
|
||||
60
mvp/Whatsapp-bot/src/leads/lead.entity.ts
Normal file
60
mvp/Whatsapp-bot/src/leads/lead.entity.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export type EstadoLead =
|
||||
| 'nuevo'
|
||||
| 'en_proceso'
|
||||
| 'recopilando_datos'
|
||||
| 'completado'
|
||||
| 'no_viable'
|
||||
| 'perdido';
|
||||
|
||||
@Entity('leads')
|
||||
export class Lead {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
nombre: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
telefono: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
email: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
espacio: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
rango_m2: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
estilo: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
urgencia: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
presupuesto_declarado: string;
|
||||
|
||||
@Column({ type: 'boolean', nullable: true })
|
||||
viable: boolean;
|
||||
|
||||
@Column({ type: 'text', default: 'nuevo' })
|
||||
estado_actual: EstadoLead;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
url_presupuesto: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
11
mvp/Whatsapp-bot/src/leads/leads.module.ts
Normal file
11
mvp/Whatsapp-bot/src/leads/leads.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Lead } from './lead.entity';
|
||||
import { LeadsService } from './leads.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Lead])],
|
||||
providers: [LeadsService],
|
||||
exports: [LeadsService],
|
||||
})
|
||||
export class LeadsModule {}
|
||||
85
mvp/Whatsapp-bot/src/leads/leads.service.ts
Normal file
85
mvp/Whatsapp-bot/src/leads/leads.service.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThan } from 'typeorm';
|
||||
import { Lead, EstadoLead } from './lead.entity';
|
||||
|
||||
@Injectable()
|
||||
export class LeadsService {
|
||||
private readonly logger = new Logger(LeadsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Lead)
|
||||
private readonly leadRepo: Repository<Lead>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Busca un lead por número de teléfono.
|
||||
* Si no existe, lo crea con estado 'nuevo'.
|
||||
*/
|
||||
async findOrCreate(telefono: string): Promise<Lead> {
|
||||
let lead = await this.leadRepo.findOne({ where: { telefono } });
|
||||
if (!lead) {
|
||||
lead = this.leadRepo.create({ telefono, estado_actual: 'nuevo' });
|
||||
lead = await this.leadRepo.save(lead);
|
||||
this.logger.log(`Lead nuevo creado: telefono=${telefono}, id=${lead.id}`);
|
||||
}
|
||||
return lead;
|
||||
}
|
||||
|
||||
async findByTelefono(telefono: string): Promise<Lead | null> {
|
||||
return this.leadRepo.findOne({ where: { telefono } });
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<Lead | null> {
|
||||
return this.leadRepo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findByEstado(estado: EstadoLead): Promise<Lead[]> {
|
||||
return this.leadRepo.find({ where: { estado_actual: estado } });
|
||||
}
|
||||
|
||||
async updateEstado(lead: Lead, estado: EstadoLead): Promise<Lead> {
|
||||
lead.estado_actual = estado;
|
||||
return this.leadRepo.save(lead);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza campos del lead según el estado actual del flujo.
|
||||
* Solo actualiza los campos que se pasan en el partial.
|
||||
*/
|
||||
async updateDatos(leadId: number, datos: Partial<Lead>): Promise<Lead> {
|
||||
await this.leadRepo.update(leadId, datos);
|
||||
return this.leadRepo.findOne({ where: { id: leadId } });
|
||||
}
|
||||
|
||||
async marcarViable(lead: Lead, viable: boolean): Promise<Lead> {
|
||||
lead.viable = viable;
|
||||
lead.estado_actual = viable ? 'completado' : 'no_viable';
|
||||
return this.leadRepo.save(lead);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca como perdido cualquier lead en_proceso sin actividad en más de 48h.
|
||||
*/
|
||||
async marcarLeadsPerdidos(): Promise<void> {
|
||||
const hace48h = new Date(Date.now() - 48 * 60 * 60 * 1000);
|
||||
const leadsSinActividad = await this.leadRepo.find({
|
||||
where: {
|
||||
estado_actual: 'en_proceso',
|
||||
updated_at: LessThan(hace48h),
|
||||
},
|
||||
});
|
||||
|
||||
for (const lead of leadsSinActividad) {
|
||||
lead.estado_actual = 'perdido';
|
||||
await this.leadRepo.save(lead);
|
||||
this.logger.warn(
|
||||
`Lead id=${lead.id} marcado como perdido por inactividad > 48h`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async save(lead: Lead): Promise<Lead> {
|
||||
return this.leadRepo.save(lead);
|
||||
}
|
||||
}
|
||||
19
mvp/Whatsapp-bot/src/main.ts
Normal file
19
mvp/Whatsapp-bot/src/main.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug'],
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
console.log(`🚀 Reformix Luisa Bot corriendo en el puerto ${port}`);
|
||||
console.log(`📡 Esperando conexion de WhatsApp...`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
8
mvp/Whatsapp-bot/src/media/media.module.ts
Normal file
8
mvp/Whatsapp-bot/src/media/media.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MediaService } from './media.service';
|
||||
|
||||
@Module({
|
||||
providers: [MediaService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
171
mvp/Whatsapp-bot/src/media/media.service.ts
Normal file
171
mvp/Whatsapp-bot/src/media/media.service.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
import { EstadoLead } from '../leads/lead.entity';
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private readonly logger = new Logger(MediaService.name);
|
||||
|
||||
private readonly OPENROUTER_URL =
|
||||
'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
private get headers() {
|
||||
return {
|
||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://reformix.es',
|
||||
'X-Title': 'Reformix Luisa Bot',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribe un audio enviándolo a Claude 4.5 como base64.
|
||||
* Baileys entrega el buffer del audio; lo convertimos a base64.
|
||||
*
|
||||
* @param audioBuffer Buffer del audio recibido por Baileys
|
||||
* @param mimeType MIME type del audio (ej: audio/ogg; codecs=opus)
|
||||
* @returns Texto transcrito, o el fallback si falla
|
||||
*/
|
||||
async transcribirAudio(
|
||||
audioBuffer: Buffer,
|
||||
mimeType = 'audio/ogg',
|
||||
): Promise<string> {
|
||||
const FALLBACK =
|
||||
'No pude escuchar bien el audio. ¿Puedes escribirme lo que me querías contar?';
|
||||
|
||||
try {
|
||||
const base64Audio = audioBuffer.toString('base64');
|
||||
|
||||
const response = await axios.post(
|
||||
this.OPENROUTER_URL,
|
||||
{
|
||||
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Por favor, transcribe exactamente lo que se dice en este audio. Devuelve solo la transcripción, sin añadir nada más.',
|
||||
},
|
||||
{
|
||||
type: 'image_url', // OpenRouter usa image_url para base64 de audio también
|
||||
image_url: {
|
||||
url: `data:${mimeType};base64,${base64Audio}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 512,
|
||||
},
|
||||
{ headers: this.headers },
|
||||
);
|
||||
|
||||
const transcripcion: string =
|
||||
response.data.choices?.[0]?.message?.content?.trim();
|
||||
|
||||
if (!transcripcion) {
|
||||
this.logger.warn('Claude devolvió respuesta vacía para el audio');
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Audio transcrito correctamente (${transcripcion.length} chars)`,
|
||||
);
|
||||
return transcripcion;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error transcribiendo audio: ${error.message}`,
|
||||
error.response?.data,
|
||||
);
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infiere información de una imagen según el estado actual del lead.
|
||||
* Útil para capturar espacios, materiales, estilos, etc.
|
||||
*
|
||||
* @param imagenBuffer Buffer de la imagen recibida por Baileys
|
||||
* @param mimeType MIME type (ej: image/jpeg)
|
||||
* @param estadoActual Estado del lead para adaptar el prompt de visión
|
||||
* @returns Texto inferido, o el fallback si falla
|
||||
*/
|
||||
async inferirImagen(
|
||||
imagenBuffer: Buffer,
|
||||
mimeType = 'image/jpeg',
|
||||
estadoActual: EstadoLead = 'en_proceso',
|
||||
): Promise<string> {
|
||||
const FALLBACK =
|
||||
'Recibí tu imagen pero no pude analizarla bien. ¿Puedes describirme lo que muestra?';
|
||||
|
||||
const promptPorEstado: Record<string, string> = {
|
||||
nuevo:
|
||||
'Describe brevemente qué tipo de espacio se ve en esta imagen y sus características principales.',
|
||||
en_proceso:
|
||||
'Describe el espacio que aparece en la imagen: tipo de habitación, materiales, estado actual, tamaño aproximado.',
|
||||
recopilando_datos:
|
||||
'Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservación.',
|
||||
completado:
|
||||
'Describe lo que ves en esta imagen relacionado con reformas o diseño de interiores.',
|
||||
no_viable:
|
||||
'Describe brevemente qué muestra esta imagen.',
|
||||
perdido:
|
||||
'Describe brevemente qué muestra esta imagen.',
|
||||
};
|
||||
|
||||
const promptDeVisión =
|
||||
promptPorEstado[estadoActual] ||
|
||||
'Describe qué ves en esta imagen en el contexto de una reforma de hogar.';
|
||||
|
||||
try {
|
||||
const base64Imagen = imagenBuffer.toString('base64');
|
||||
|
||||
const response = await axios.post(
|
||||
this.OPENROUTER_URL,
|
||||
{
|
||||
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: promptDeVisión,
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${mimeType};base64,${base64Imagen}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 512,
|
||||
},
|
||||
{ headers: this.headers },
|
||||
);
|
||||
|
||||
const inferencia: string =
|
||||
response.data.choices?.[0]?.message?.content?.trim();
|
||||
|
||||
if (!inferencia) {
|
||||
this.logger.warn('Claude devolvió respuesta vacía para la imagen');
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Imagen inferida correctamente (${inferencia.length} chars)`,
|
||||
);
|
||||
return inferencia;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error analizando imagen: ${error.message}`,
|
||||
error.response?.data,
|
||||
);
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
mvp/Whatsapp-bot/src/scheduler/scheduler.module.ts
Normal file
12
mvp/Whatsapp-bot/src/scheduler/scheduler.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SchedulerService } from './scheduler.service';
|
||||
import { LeadsModule } from '../leads/leads.module';
|
||||
import { ConversacionModule } from '../conversacion/conversacion.module';
|
||||
import { WhatsappModule } from '../whatsapp/whatsapp.module';
|
||||
import { ClaudeModule } from '../claude/claude.module';
|
||||
|
||||
@Module({
|
||||
imports: [LeadsModule, ConversacionModule, WhatsappModule, ClaudeModule],
|
||||
providers: [SchedulerService],
|
||||
})
|
||||
export class SchedulerModule {}
|
||||
86
mvp/Whatsapp-bot/src/scheduler/scheduler.service.ts
Normal file
86
mvp/Whatsapp-bot/src/scheduler/scheduler.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { LeadsService } from '../leads/leads.service';
|
||||
import { ConversacionService } from '../conversacion/conversacion.service';
|
||||
import { WhatsappService } from '../whatsapp/whatsapp.service';
|
||||
import { ClaudeService } from '../claude/claude.service';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulerService {
|
||||
private readonly logger = new Logger(SchedulerService.name);
|
||||
|
||||
constructor(
|
||||
private readonly leadsService: LeadsService,
|
||||
private readonly conversacionService: ConversacionService,
|
||||
private readonly whatsappService: WhatsappService,
|
||||
private readonly claudeService: ClaudeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Cada 5 minutos:
|
||||
* 1. Busca leads con estado_actual = 'nuevo'
|
||||
* 2. Los marca como 'en_proceso'
|
||||
* 3. Les envía el mensaje de APERTURA de Luisa
|
||||
*
|
||||
* También marca como perdidos los leads en_proceso sin actividad > 48h.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||
async procesarLeadsNuevos(): Promise<void> {
|
||||
this.logger.log('[Scheduler] Buscando leads nuevos...');
|
||||
|
||||
// Primero limpiar leads inactivos
|
||||
await this.leadsService.marcarLeadsPerdidos();
|
||||
|
||||
// Obtener leads nuevos
|
||||
const leadsNuevos = await this.leadsService.findByEstado('nuevo');
|
||||
|
||||
if (leadsNuevos.length === 0) {
|
||||
this.logger.log('[Scheduler] No hay leads nuevos.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[Scheduler] Procesando ${leadsNuevos.length} lead(s) nuevo(s).`,
|
||||
);
|
||||
|
||||
for (const lead of leadsNuevos) {
|
||||
try {
|
||||
// Marcar como en_proceso antes de hacer nada
|
||||
await this.leadsService.updateEstado(lead, 'en_proceso');
|
||||
this.logger.log(
|
||||
`[Scheduler] Lead id=${lead.id} marcado como en_proceso.`,
|
||||
);
|
||||
|
||||
// Generar mensaje de apertura con Claude usando contexto mínimo
|
||||
const historialVacio: Array<{ role: string; content: string }> = [];
|
||||
const mensajeDeApertura =
|
||||
'APERTURA: Este es el primer mensaje. Preséntate y comienza el flujo de cualificación.';
|
||||
|
||||
const { respuesta } = await this.claudeService.llamarClaude(
|
||||
lead,
|
||||
historialVacio,
|
||||
mensajeDeApertura,
|
||||
);
|
||||
|
||||
// Guardar el mensaje de apertura en historial (como assistant)
|
||||
await this.conversacionService.guardarMensaje(
|
||||
lead.id,
|
||||
'assistant',
|
||||
respuesta,
|
||||
);
|
||||
|
||||
// Enviar por WhatsApp
|
||||
await this.whatsappService.enviarApertura(lead.telefono, respuesta);
|
||||
|
||||
this.logger.log(
|
||||
`[Scheduler] Apertura enviada a lead id=${lead.id} (${lead.telefono}).`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[Scheduler] Error procesando lead id=${lead.id}: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
mvp/Whatsapp-bot/src/whatsapp/whatsapp.module.ts
Normal file
13
mvp/Whatsapp-bot/src/whatsapp/whatsapp.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WhatsappService } from './whatsapp.service';
|
||||
import { LeadsModule } from '../leads/leads.module';
|
||||
import { ConversacionModule } from '../conversacion/conversacion.module';
|
||||
import { ClaudeModule } from '../claude/claude.module';
|
||||
import { MediaModule } from '../media/media.module';
|
||||
|
||||
@Module({
|
||||
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule],
|
||||
providers: [WhatsappService],
|
||||
exports: [WhatsappService],
|
||||
})
|
||||
export class WhatsappModule {}
|
||||
252
mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts
Normal file
252
mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import makeWASocket, {
|
||||
DisconnectReason,
|
||||
useMultiFileAuthState,
|
||||
WASocket,
|
||||
downloadMediaMessage,
|
||||
proto,
|
||||
} from '@whiskeysockets/baileys';
|
||||
import { Boom } from '@hapi/boom';
|
||||
import * as path from 'path';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pino = require('pino');
|
||||
import { LeadsService } from '../leads/leads.service';
|
||||
import { ConversacionService } from '../conversacion/conversacion.service';
|
||||
import { ClaudeService } from '../claude/claude.service';
|
||||
import { MediaService } from '../media/media.service';
|
||||
import { Lead } from '../leads/lead.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(WhatsappService.name);
|
||||
private sock: WASocket | null = null;
|
||||
private authDir = path.join(process.cwd(), 'auth_info_baileys');
|
||||
|
||||
constructor(
|
||||
private readonly leadsService: LeadsService,
|
||||
private readonly conversacionService: ConversacionService,
|
||||
private readonly claudeService: ClaudeService,
|
||||
private readonly mediaService: MediaService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.conectar();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.sock) {
|
||||
this.sock.end(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async conectar() {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
|
||||
|
||||
this.sock = makeWASocket({
|
||||
auth: state,
|
||||
printQRInTerminal: true,
|
||||
logger: pino({ level: 'silent' }) as any,
|
||||
});
|
||||
|
||||
this.sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
this.sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
console.log('\n📲 Escanea el QR de arriba con WhatsApp\n');
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const shouldReconnect =
|
||||
(lastDisconnect?.error as Boom)?.output?.statusCode !==
|
||||
DisconnectReason.loggedOut;
|
||||
|
||||
this.logger.warn(
|
||||
`Conexion cerrada. Reconectar: ${shouldReconnect}. Razon: ${lastDisconnect?.error?.message}`,
|
||||
);
|
||||
|
||||
if (shouldReconnect) {
|
||||
setTimeout(() => this.conectar(), 5000);
|
||||
} else {
|
||||
this.logger.error(
|
||||
'Sesion cerrada (logged out). Elimina auth_info_baileys y reinicia.',
|
||||
);
|
||||
}
|
||||
} else if (connection === 'open') {
|
||||
this.logger.log(
|
||||
'✅ WhatsApp conectado. Luisa está lista para recibir mensajes.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||
if (type !== 'notify') return;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.key.fromMe) continue; // ignorar mensajes propios
|
||||
if (!msg.key.remoteJid) continue;
|
||||
|
||||
await this.procesarMensaje(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa un mensaje entrante de WhatsApp.
|
||||
* Identifica el tipo (texto, audio, imagen), normaliza el contenido,
|
||||
* consulta/crea el lead, llama a Claude y envía la respuesta.
|
||||
*/
|
||||
private async procesarMensaje(
|
||||
msg: proto.IWebMessageInfo,
|
||||
): Promise<void> {
|
||||
const jid = msg.key.remoteJid!;
|
||||
// Normalizar el número de teléfono (quitar el @s.whatsapp.net y el sufijo de grupo)
|
||||
const telefono = jid.replace('@s.whatsapp.net', '').replace('@g.us', '');
|
||||
|
||||
try {
|
||||
// 1. Identificar o crear el lead
|
||||
const lead = await this.leadsService.findOrCreate(telefono);
|
||||
|
||||
// Ignorar leads ya terminados
|
||||
if (['completado', 'no_viable', 'perdido'].includes(lead.estado_actual)) {
|
||||
this.logger.log(
|
||||
`Lead id=${lead.id} en estado=${lead.estado_actual}. Mensaje ignorado.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Determinar el tipo de mensaje y normalizarlo a texto
|
||||
let textoNormalizado = '';
|
||||
const msgContent = msg.message;
|
||||
|
||||
if (!msgContent) return;
|
||||
|
||||
if (msgContent.conversation || msgContent.extendedTextMessage) {
|
||||
// Texto plano
|
||||
textoNormalizado =
|
||||
msgContent.conversation ||
|
||||
msgContent.extendedTextMessage?.text ||
|
||||
'';
|
||||
} else if (msgContent.audioMessage) {
|
||||
// Audio → Claude transcripción
|
||||
this.logger.log(`Audio recibido de lead id=${lead.id}. Transcribiendo...`);
|
||||
const buffer = await downloadMediaMessage(msg, 'buffer', {});
|
||||
const mimeType =
|
||||
msgContent.audioMessage.mimetype || 'audio/ogg; codecs=opus';
|
||||
textoNormalizado = await this.mediaService.transcribirAudio(
|
||||
buffer as Buffer,
|
||||
mimeType,
|
||||
);
|
||||
} else if (msgContent.imageMessage) {
|
||||
// Imagen → Claude Vision
|
||||
this.logger.log(
|
||||
`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`,
|
||||
);
|
||||
const buffer = await downloadMediaMessage(msg, 'buffer', {});
|
||||
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
|
||||
textoNormalizado = await this.mediaService.inferirImagen(
|
||||
buffer as Buffer,
|
||||
mimeType,
|
||||
lead.estado_actual,
|
||||
);
|
||||
|
||||
// Si el lead envió un caption junto con la imagen, concatenarlo
|
||||
if (msgContent.imageMessage.caption) {
|
||||
textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`;
|
||||
}
|
||||
} else {
|
||||
this.logger.log(
|
||||
`Tipo de mensaje no soportado de lead id=${lead.id}. Ignorando.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!textoNormalizado.trim()) return;
|
||||
|
||||
// 3. Guardar el mensaje del usuario en historial
|
||||
await this.conversacionService.guardarMensaje(
|
||||
lead.id,
|
||||
'user',
|
||||
textoNormalizado,
|
||||
);
|
||||
|
||||
// 4. Construir historial y llamar a Claude
|
||||
const historial =
|
||||
await this.conversacionService.obtenerHistorialComoMessages(lead.id);
|
||||
|
||||
const { respuesta, entidad, viable } = await this.claudeService.llamarClaude(
|
||||
lead,
|
||||
historial.slice(0, -1), // el último ya es el mensaje actual
|
||||
textoNormalizado,
|
||||
);
|
||||
|
||||
// 5. Actualizar datos del lead con lo extraído por Claude
|
||||
if (entidad && Object.keys(entidad).length > 0) {
|
||||
await this.leadsService.updateDatos(lead.id, entidad);
|
||||
}
|
||||
|
||||
// 6. Manejar el flag viable
|
||||
if (viable !== undefined && viable !== null) {
|
||||
await this.leadsService.marcarViable(lead, viable);
|
||||
this.logger.log(
|
||||
`Lead id=${lead.id} marcado como viable=${viable}`,
|
||||
);
|
||||
} else {
|
||||
// Avanzar estado si sigue en_proceso
|
||||
if (lead.estado_actual === 'nuevo') {
|
||||
await this.leadsService.updateEstado(lead, 'en_proceso');
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Guardar respuesta de Claude en historial
|
||||
await this.conversacionService.guardarMensaje(
|
||||
lead.id,
|
||||
'assistant',
|
||||
respuesta,
|
||||
);
|
||||
|
||||
// 8. Enviar respuesta por WhatsApp
|
||||
await this.enviarMensaje(jid, respuesta);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error procesando mensaje de ${telefono}: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía un mensaje de texto por WhatsApp.
|
||||
*/
|
||||
async enviarMensaje(jid: string, texto: string): Promise<void> {
|
||||
if (!this.sock) {
|
||||
this.logger.error('Socket de WhatsApp no disponible');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.sock.sendMessage(jid, { text: texto });
|
||||
this.logger.log(`Mensaje enviado a ${jid}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error enviando mensaje a ${jid}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía el mensaje de apertura de Luisa a un número de teléfono.
|
||||
* Lo usa el Scheduler para disparar el primer contacto.
|
||||
*/
|
||||
async enviarApertura(telefono: string, mensajeApertura: string): Promise<void> {
|
||||
const jid = `${telefono}@s.whatsapp.net`;
|
||||
await this.enviarMensaje(jid, mensajeApertura);
|
||||
}
|
||||
|
||||
isConectado(): boolean {
|
||||
return this.sock !== null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user